@roottale/analytics-runtime 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -108,6 +108,7 @@ var ATTRIBUTION_STORAGE_KEY = "_rt_attr";
108
108
  var ATTRIBUTION_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
109
109
  var RUNTIME2 = `(function(){
110
110
  var C=__RT_BEACON__;
111
+ function 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;}}
111
112
  function send(ev,extra){
112
113
  try{
113
114
  var p={ev:ev,sid:C.sid,path:location.pathname,ref:document.referrer?1:0,ts:Date.now()};
@@ -143,7 +144,7 @@ localStorage.setItem(AK,JSON.stringify(a));
143
144
  }
144
145
  }catch(e){}
145
146
  }
146
- if(C.pv){send('pageview',null);}
147
+ if(C.pv){var _pid=metaPid();send('pageview',_pid?{pid:_pid}:null);}
147
148
  })();`;
148
149
  function renderBeaconScript(options) {
149
150
  const cfg = JSON.stringify({
@@ -164,4 +165,4 @@ export {
164
165
  ATTRIBUTION_TTL_MS,
165
166
  renderBeaconScript
166
167
  };
167
- //# sourceMappingURL=chunk-HWZ7BLEM.js.map
168
+ //# sourceMappingURL=chunk-FPKZZSGL.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/loader.ts","../src/consent-banner.ts","../src/beacon.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\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);},\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 \"이 사이트는 방문 분석·광고 도구를 사용할 수 있습니다. 동의 여부를 선택해 주세요.\";\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, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\");\n}\n\nfunction escapeAttr(s: string): string {\n return escapeHtml(s).replace(/\"/g, \"&quot;\");\n}\n","// 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// 동의 면제 조건(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\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 */\nexport interface FirstTouchAttribution {\n /** 포맷 버전. */\n v: 1;\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 /** 외부 referrer hostname — raw URL 미저장, 동일 host 면 생략. */\n rf?: string;\n}\n\nconst RUNTIME = `(function(){\nvar C=__RT_BEACON__;\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){}\n}\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){}\nif(!cur||typeof cur.ts!=='number'||Date.now()-cur.ts>2592e6){\nvar sp=new URLSearchParams(location.search);\nvar a={v:1,lp:location.pathname,ts:Date.now()};\nvar rs=sp.get('rt_src');if(rs)a.rs=rs.slice(0,32);\nvar us=sp.get('utm_source');if(us)a.us=us.slice(0,100);\nvar um=sp.get('utm_medium');if(um)a.um=um.slice(0,100);\nvar uc=sp.get('utm_campaign');if(uc)a.uc=uc.slice(0,100);\ntry{if(document.referrer){var rh=new URL(document.referrer).hostname;if(rh&&rh!==location.hostname)a.rf=rh.slice(0,253);}}catch(e){}\nlocalStorage.setItem(AK,JSON.stringify(a));\n}\n}catch(e){}\n}\nif(C.pv){send('pageview',null);}\n})();`;\n\n/**\n * 비콘 dispatcher JS 본문. <script is:inline> 로 감싸 주입.\n * `ref` 는 referrer 존재 여부(0/1)만 — raw referrer 미전송(privacy).\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"],"mappings":";AAeO,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;AA2CT,SAAS,mBAAmB,UAAqC;AACtE,SAAO,yBAAyB,KAAK,UAAU,QAAQ,IAAI,MAAM;AACnE;;;ACxDA,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;;;ACnDO,IAAM,0BAA0B;AAGhC,IAAM,qBAAqB,KAAK,KAAK,KAAK,KAAK;AAyBtD,IAAMA,WAAU;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;AA4CT,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,MAAMA;AAC5C;","names":["RUNTIME"]}
1
+ {"version":3,"sources":["../src/loader.ts","../src/consent-banner.ts","../src/beacon.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\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);},\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 \"이 사이트는 방문 분석·광고 도구를 사용할 수 있습니다. 동의 여부를 선택해 주세요.\";\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, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\");\n}\n\nfunction escapeAttr(s: string): string {\n return escapeHtml(s).replace(/\"/g, \"&quot;\");\n}\n","// 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// 동의 면제 조건(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\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 */\nexport interface FirstTouchAttribution {\n /** 포맷 버전. */\n v: 1;\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 /** 외부 referrer hostname — raw URL 미저장, 동일 host 면 생략. */\n rf?: string;\n}\n\nconst RUNTIME = `(function(){\nvar C=__RT_BEACON__;\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){}\n}\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){}\nif(!cur||typeof cur.ts!=='number'||Date.now()-cur.ts>2592e6){\nvar sp=new URLSearchParams(location.search);\nvar a={v:1,lp:location.pathname,ts:Date.now()};\nvar rs=sp.get('rt_src');if(rs)a.rs=rs.slice(0,32);\nvar us=sp.get('utm_source');if(us)a.us=us.slice(0,100);\nvar um=sp.get('utm_medium');if(um)a.um=um.slice(0,100);\nvar uc=sp.get('utm_campaign');if(uc)a.uc=uc.slice(0,100);\ntry{if(document.referrer){var rh=new URL(document.referrer).hostname;if(rh&&rh!==location.hostname)a.rf=rh.slice(0,253);}}catch(e){}\nlocalStorage.setItem(AK,JSON.stringify(a));\n}\n}catch(e){}\n}\nif(C.pv){var _pid=metaPid();send('pageview',_pid?{pid:_pid}:null);}\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"],"mappings":";AAeO,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;AA2CT,SAAS,mBAAmB,UAAqC;AACtE,SAAO,yBAAyB,KAAK,UAAU,QAAQ,IAAI,MAAM;AACnE;;;ACxDA,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;;;ACnDO,IAAM,0BAA0B;AAGhC,IAAM,qBAAqB,KAAK,KAAK,KAAK,KAAK;AAyBtD,IAAMA,WAAU;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;AAmDT,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,MAAMA;AAC5C;","names":["RUNTIME"]}
package/dist/index.d.ts CHANGED
@@ -77,6 +77,12 @@ interface FirstTouchAttribution {
77
77
  /**
78
78
  * 비콘 dispatcher JS 본문. <script is:inline> 로 감싸 주입.
79
79
  * `ref` 는 referrer 존재 여부(0/1)만 — raw referrer 미전송(privacy).
80
+ *
81
+ * ADR-0065 — 글 조회수 식별자: pageview 시 `<meta name="rt:content-id">` 의
82
+ * content 를 읽어 `pid`(=post_id) 로 동봉한다. 사이트 레벨 단일 비콘이 페이지별
83
+ * 글 식별자를 갖게 해 URL/컬렉션 basePath 변경에 무관한 안정 매칭을 만든다.
84
+ * meta 미존재 시 `pid` 생략(graceful) — `/collect` 가 post_id 없는 pageview 로 처리.
85
+ * 값 형태는 `/v1/collect` 와의 공유 계약(변경 시 양쪽 동시 갱신).
80
86
  */
81
87
  declare function renderBeaconScript(options: BeaconOptions): string;
82
88
 
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  renderConsentBannerScript,
7
7
  renderConsentBootstrap,
8
8
  renderLoaderScript
9
- } from "./chunk-HWZ7BLEM.js";
9
+ } from "./chunk-FPKZZSGL.js";
10
10
 
11
11
  // src/consent.ts
12
12
  var ALL_CONSENT_CATEGORIES = [
package/dist/react.js CHANGED
@@ -4,7 +4,7 @@ import {
4
4
  renderConsentBannerScript,
5
5
  renderConsentBootstrap,
6
6
  renderLoaderScript
7
- } from "./chunk-HWZ7BLEM.js";
7
+ } from "./chunk-FPKZZSGL.js";
8
8
 
9
9
  // src/react.tsx
10
10
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@roottale/analytics-runtime",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "RootTale analytics runtime (ADR-0038) — Consent Mode v2 fail-closed tag loader, consent banner, and first-party data-track beacon. Framework-agnostic strings + optional React drop-in (<RootTaleAnalytics>).",
6
6
  "main": "./dist/index.js",