@metricinsights/pp-dev 1.2.2 → 1.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.
- package/LICENSE +21 -0
- package/dist/cjs/cli.js +1 -1
- package/dist/cjs/cli.js.map +1 -1
- package/dist/cjs/{index-BYpGxymF.js → index-xObEyFiP.js} +2 -2
- package/dist/cjs/{index-BYpGxymF.js.map → index-xObEyFiP.js.map} +1 -1
- package/dist/cjs/index.js +1 -1
- package/dist/cjs/package.json +2 -2
- package/dist/cjs/plugin-DXUOFhPN.js +2 -0
- package/dist/cjs/plugin-DXUOFhPN.js.map +1 -0
- package/dist/cjs/plugin.js +1 -1
- package/dist/cjs/version-plugin-BXydoNOH.js +2 -0
- package/dist/cjs/version-plugin-BXydoNOH.js.map +1 -0
- package/dist/esm/cli.js +1 -1
- package/dist/esm/cli.js.map +1 -1
- package/dist/esm/{index-C7yA2HV8.js → index-BDerKo6V.js} +2 -2
- package/dist/esm/{index-C7yA2HV8.js.map → index-BDerKo6V.js.map} +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/package.json +2 -2
- package/dist/esm/plugin-CHYaG0Px.js +2 -0
- package/dist/esm/plugin-CHYaG0Px.js.map +1 -0
- package/dist/esm/plugin.js +1 -1
- package/dist/esm/version-plugin-gYCnpPok.js +2 -0
- package/dist/esm/{version-plugin-CgfQOMyf.js.map → version-plugin-gYCnpPok.js.map} +1 -1
- package/package.json +3 -3
- package/dist/cjs/plugin-BHngnaEU.js +0 -2
- package/dist/cjs/plugin-BHngnaEU.js.map +0 -1
- package/dist/cjs/version-plugin-7W1v65PU.js +0 -2
- package/dist/cjs/version-plugin-7W1v65PU.js.map +0 -1
- package/dist/esm/plugin-FSAnUcfG.js +0 -2
- package/dist/esm/plugin-FSAnUcfG.js.map +0 -1
- package/dist/esm/version-plugin-CgfQOMyf.js +0 -2
|
@@ -1,2 +0,0 @@
|
|
|
1
|
-
import{createProxyMiddleware as e,responseInterceptor as t}from"http-proxy-middleware";import{fileURLToPath as n,URL as a}from"url";import{createLogger as s}from"vite";import{createColors as o}from"picocolors";import i,{isAxiosError as r}from"axios";import l from"node:http";import c from"node:https";import{JSDOM as d}from"jsdom";import*as p from"path";import u,{resolve as h}from"path";import{readFileSync as m,existsSync as g,statSync as f,mkdirSync as v,writeFileSync as b,readdirSync as y,promises as w}from"fs";import*as x from"crypto";import{createHash as k,randomUUID as S}from"crypto";import*as E from"process";import*as T from"child_process";import{execSync as I}from"child_process";import*as A from"os";import R from"extract-zip";import $ from"node:path";import O from"jszip";import{createRequire as B}from"module";import*as C from"memory-cache";import{inflateSync as _,brotliDecompressSync as j,unzipSync as N,deflateSync as H,brotliCompressSync as F,gzipSync as L}from"zlib";import M from"express";import{randomUUID as P}from"node:crypto";const D=h("undefined"!=typeof __filename&&__filename||n(import.meta.url),"../../.."),V=h("undefined"!=typeof __filename&&__filename||n(import.meta.url),"../.."),q=g(h(D,"package.json"))?D:V,z=h(q,"dist/client/client.js"),{version:U,name:J}=JSON.parse(m(h(q,"package.json")).toString()),W=U,Y=J,K="/@pp-dev-hmr",Z="/p",G="/pt",X="/pl",Q="PP_DEV_NEXT_BUILD",ee=[".pp-dev.config.js",".pp-dev.config.cjs",".pp-dev.config.mjs",".pp-dev.config.ts",".pp-dev.config.cts",".pp-dev.config.mts",".pp-dev.config.json","pp-dev.config.js","pp-dev.config.cjs","pp-dev.config.mjs","pp-dev.config.ts","pp-dev.config.cts","pp-dev.config.mts","pp-dev.config.json"];let te=new Map;const ne="default",ae=(e="info",t=ne)=>{if(te&&"function"==typeof te.has||(te=new Map),te.has(t))return te.get(t);if(t===ne){const n=s(e);return te.set(t,n),n}const n=s(e);return te.set(t,n),n},se=o(),oe=(e,t,n)=>{const a=new RegExp(`(!!)?(https?(:(\\\\)?/(\\\\)?/)${e})`,"gi");return n.replace(a,(e,...n)=>e.startsWith("!!")?n[1]:`http${n[2]}${t}`)},ie=(e,t,n)=>{e.setHeader("location",t),e.statusCode=n,e.end()};const re=function(){const e={personal:{placeholder:"API Token",caption:`You can get the API Token\n <a href="https://${host}/api-token" target="_blank" \n style="color: #007bff; text-decoration: none;">here</a>`,endpoint:"/@api/login"},regular:{placeholder:"Legacy Token",caption:`You can get the Legacy Token\n <a href="https://${host}/api/get_token" target="_blank" \n style="color: #007bff; text-decoration: none;">here</a>`,endpoint:"/@api/login"}};function t(t,n,a){const s=e[t];s&&(n.placeholder=s.placeholder,a.innerHTML=s.caption)}function n(e,t){t.textContent=e,t.classList.add("show")}function a(e){e.classList.remove("show")}function s(e,t,s,o){s.addEventListener("click",async s=>{if(s.preventDefault(),a(o),!t.value.trim())return n(`${t.placeholder} is required`,o),void t.focus();const i=document.getElementById("token-type-switcher"),r=i?.querySelector(".token-type-option.active"),l=r?.dataset.value||"personal";await async function(e,t,a,s){try{a.classList.add("loading");const o=await fetch("/@api/login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:e,tokenType:t}),redirect:"follow"}),i=o.url&&new URL(o.url,window.location.origin);if(!i||i.pathname.startsWith("/login")||i.pathname.startsWith("/@api/"))try{const e=await o.json();n(`Login failed: ${e?.error||"Unknown error occurred"}`,s)}catch(e){n("Login failed: Unable to parse response",s)}else window.location.href=o.url}catch(e){n("Login failed: Network error. Please check your connection.",s)}finally{a.classList.remove("loading")}}(t.value.trim(),l,e,o)}),t.addEventListener("keydown",e=>{"Enter"===e.key&&(e.preventDefault(),s.click())}),t.addEventListener("input",()=>{a(o)})}function o(e){const n=e.firstChild;if(!n)return;const a=n.lastChild?.cloneNode(!0);if(!a)return;a.innerHTML="";const o=a.cloneNode(!0);o.innerText="OR",o.setAttribute("role","separator"),o.setAttribute("aria-label","Alternative login method");const i=a.cloneNode(!0),r=document.createElement("div"),l=document.createElement("style");l.textContent="\n.helper-login-wrapper {\n font-family: Arial, sans-serif;\n color: #222;\n padding: 8px;\n background-color: #f8f9fa;\n border-radius: 8px;\n border: 1px solid #ddd;\n width: 100%;\n max-width: 400px;\n margin: 0 auto;\n box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);\n}\n\n.helper-login-wrapper .title {\n font-weight: bold;\n font-size: 18px;\n margin-bottom: 12px;\n text-align: center;\n}\n\n.helper-login-wrapper .control {\n display: flex;\n flex-direction: column;\n gap: 12px;\n}\n\n.helper-login-wrapper .control input {\n border: 1px solid #ddd;\n border-radius: 4px;\n width: 100%;\n box-sizing: border-box;\n text-align: center;\n height: 26px;\n padding: 0 8px;\n font-size: 14px;\n}\n\n.helper-login-wrapper .control input:focus {\n outline: none;\n border-color: #007bff;\n box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);\n}\n\n.helper-login-wrapper .control .caption {\n font-size: 12px;\n color: #666;\n text-align: center;\n}\n\n.helper-login-wrapper .footer {\n margin-top: 16px;\n text-align: center;\n}\n\n.helper-login-wrapper .footer .btn.submit {\n background-color: #007bff;\n color: white;\n border: none;\n border-radius: 4px;\n cursor: pointer;\n width: 100%;\n font-size: 14px;\n transition: background-color 0.3s;\n height: 26px;\n font-weight: 500;\n}\n\n.helper-login-wrapper .footer .btn.submit:hover {\n background-color: #0056b3;\n}\n\n.helper-login-wrapper .footer .btn.submit:disabled {\n background-color: #6c757d;\n cursor: not-allowed;\n}\n\n.helper-login-wrapper .token-type-switcher {\n display: flex;\n border: 1px solid #ccc;\n border-radius: 4px;\n overflow: hidden;\n margin-bottom: 12px;\n width: 100%;\n}\n\n.helper-login-wrapper .token-type-option {\n padding: 4px 8px;\n cursor: pointer;\n background-color: #f8f9fa;\n color: #666;\n font-size: 12px;\n text-align: center;\n transition: background-color 0.2s, color 0.2s;\n border-right: 1px solid #ccc;\n flex: 1;\n user-select: none;\n}\n\n.helper-login-wrapper .token-type-option:last-child {\n border-right: none;\n}\n\n.helper-login-wrapper .token-type-option.active {\n background-color: #007bff;\n color: white;\n font-weight: bold;\n}\n\n.helper-login-wrapper .token-type-option:not(.active):hover {\n background-color: #e9ecef;\n}\n\n.helper-login-wrapper .error-message {\n color: #dc3545;\n font-size: 12px;\n text-align: center;\n margin-top: 8px;\n display: none;\n}\n\n.helper-login-wrapper .error-message.show {\n display: block;\n}\n\n.helper-login-wrapper .loading {\n opacity: 0.6;\n pointer-events: none;\n}\n",document.head.appendChild(l),r.innerHTML=`\n<div class="helper-login-wrapper" role="form" aria-label="PP Dev Helper Login">\n <div class="title" role="heading" aria-level="2">PP Dev Helper</div>\n\n <div class="token-type-switcher" id="token-type-switcher" role="tablist" aria-label="Token type selection">\n <div class="token-type-option active" data-value="personal" role="tab" aria-selected="true" tabindex="0">\n API Token\n </div>\n <div class="token-type-option" data-value="regular" role="tab" aria-selected="false" tabindex="0">\n Legacy Token\n </div>\n </div>\n\n <div class="control">\n <input \n type="password" \n id="helper-token" \n placeholder="API Token"\n aria-label="Token input"\n aria-describedby="token-caption"\n >\n <span class="caption" id="token-caption">\n You can get the API Token\n <a href="https://${host}/api-token" target="_blank" style="color: #007bff; text-decoration: none;">here</a>\n </span>\n </div>\n\n <div class="error-message" id="error-message" role="alert" aria-live="polite"></div>\n\n <div class="footer">\n <button id="helper-token-submit" class="btn submit" type="button">\n Token Login\n </button>\n </div>\n</div>`,i.appendChild(r),n.appendChild(o),n.appendChild(i);const c=document.getElementById("token-type-switcher"),d=document.getElementById("helper-token"),p=document.getElementById("token-caption"),u=document.getElementById("helper-token-submit"),h=document.getElementById("error-message"),m=r.querySelector(".helper-login-wrapper");c&&d&&p&&u&&h&&m?(!function(e,n,a){const s=e.querySelectorAll(".token-type-option"),o=e=>{const o=e.dataset.value;s.forEach(e=>{e.classList.remove("active"),e.setAttribute("aria-selected","false"),e.setAttribute("tabindex","-1")}),e.classList.add("active"),e.setAttribute("aria-selected","true"),e.setAttribute("tabindex","0"),t(o,n,a)};s.forEach(e=>{e.addEventListener("click",e=>{const t=e.currentTarget;o(t)}),e.addEventListener("keydown",t=>{"Enter"!==t.key&&" "!==t.key||(t.preventDefault(),o(e))})})}(c,d,p),s(m,d,u,h),t("personal",d,p)):console.error("Failed to find required form elements")}const i='#mi-react-root form:not([id]):has(input[name="login"]):has(input[name="password"])',r=new MutationObserver(e=>{for(const t of e)if("childList"===t.type){const e=document.querySelector(i);if(e){r.disconnect(),o(e);break}}});r.observe(document.body,{childList:!0,subtree:!0});const l=document.querySelector(i);l&&(r.disconnect(),o(l))},le=/^(https?:\/\/)([^/]+)(\/.*)?$/i,ce="X-PP-Proxy";function de(n){const{rewritePath:s=/^\/(?!p[tl]).*/i,baseURL:o="",disableSSLValidation:i=!1,miAPI:r,templateName:l}=n;if(!o)throw new Error("Base url is required");const c=o.replace(le,"$2"),d=o.replace(le,"$1$2"),p=import("file-type"),u=ae();return e({selfHandleResponse:!0,pathFilter:(e,t)=>!(n.proxyIgnore||[]).some(t=>"string"==typeof t?e.startsWith(t):"function"!=typeof t.test||t.test(e))&&("string"==typeof s?e.startsWith(s):Array.isArray(s)?s.some(t=>e.startsWith(t)):"function"==typeof s.test&&s.test(e)),target:o,changeOrigin:!0,autoRewrite:!0,cookieDomainRewrite:{[c]:"localhost"},logger:{info:()=>{},warn:()=>{},error:()=>{}},secure:!i,headers:{host:c,origin:d},on:{proxyReq(e,t,n){const s=t.headers.host,i=e.getHeader("referer");s&&i&&"string"==typeof i&&e.setHeader("referer",i.replace(new RegExp(`https?://${s}`),o)),r.personalAccessToken&&e.setHeader("Authorization",`Bearer ${r.personalAccessToken}`);const c=t.url??"/",d=function(e,t,n,s){if(!t||!n||!s||n===s)return e;try{const t=new a(e,"http://localhost"),o=t.pathname.match(/^\/data\/page\/([^/]+)(\/.*)?$/);if(!o)return e;let i;try{i=decodeURIComponent(o[1])}catch{i=o[1]}if(i!==n)return e;return`/data/page/${s}${o[2]??""}`+t.search+t.hash}catch{return e}}(c,r.v7Features,l,r.internalPageName);return d!==c&&(t.url=d,e.path=d,console.info(`${se.cyan("Rewrites data page to correct internal page name")}: ${se.yellow(c)} ${se.blue("→")} ${se.green(d)}`)),u.info(`${se.blue("Proxies request:")} ${se.green(t.method)} ${t.url} -> ${se.green(e.method)} ${e.protocol}//${e.host}${e.path}`),t.socket.setMaxListeners(0),t.socket.once("close",()=>{setTimeout(()=>{e.destroyed||e.destroy()},200)}),e},proxyRes:(e,n,a)=>{if(e.headers["content-type"]?.includes("text/event-stream")||e.headers["transfer-encoding"]?.includes("chunked")&&"no"===e.headers["x-accel-buffering"]){u.info(`${se.blue("Start streaming for request:")} ${se.green(n.method)} ${n.url}`);const t=async(e,t,n)=>{n.setHeader(ce,1),n.setHeaders(new Map(Object.entries(e.headers))),e.pipe(n)};return t(e,n,a)}const s=t(async(e,t,n,a)=>{a.setHeader(ce,1);if(await(await p).fileTypeFromBuffer(e))return e;{let t=e.toString("utf8");try{const e=new URL(n.url??"",`http://${c}`);if(e.searchParams&&e.searchParams.has("proxyRedirect")){t+=`<script>(${function(){const e="pp-dev::redirectCount",t="pp-dev::lastRedirect";let n=+(localStorage.getItem(e)??0);localStorage.getItem(t);Number.isNaN(n)&&(n=0);let a=window.location.href;const s=function(){const o=new URLSearchParams(window.location.search);if(!o.has("proxyRedirect"))return console.debug("No proxyRedirect param. Cleaning up."),void localStorage.removeItem(e);a!==window.location.href?(a=window.location.href,setTimeout(s,n<3?3e3:5e3)):window.location.href=o.get("proxyRedirect"),localStorage.setItem(e,""+ ++n),localStorage.setItem(t,(new Date).toISOString())};setTimeout(s,3e3)}.toString()})()<\/script>`}e.pathname.startsWith("/login")&&(a.setHeader("Cache-Control","no-cache, no-store, must-revalidate"),a.setHeader("Pragma","no-cache"),a.setHeader("Expires","0"));const s=t.slice(0,100);(s.includes("<html")||s.includes("<body")||s.includes("<head")||s.includes("html>"))&&(t+=`<script>const host = "${c}";\n(${re.toString()})()<\/script>`)}catch{}const s=n.headers.host??"";return((e,t,n)=>{const a=new RegExp(`${e.replace(/\\*\//gi,"\\\\/")}`,"gi"),s=new RegExp(`${e}`,"gi"),o=n.replace(a,t);return o===n?o.replace(s,t):o})("/auth/saml/login","/login",oe(c,s,t))}});return s(e,n,a)},error(e,t,n){const a=`Proxy error: "${e.message}" when trying to "${t.method} ${t.url}"\n\n${e.stack}`;u.error(a),n.writable&&n.writeHead(500,{"Content-Type":"text/plain"}),n.end(a)}}})}class pe{axios;constructor(e){this.axios=e}async checkAuth(e){return this.axios.get("/data/page/index/auth/info",{headers:Object.assign({},e,{accept:"text/html"}),maxRedirects:0}).then(()=>!0).catch(()=>!1)}}class ue extends pe{formdataModulePromise=import("formdata-node");constructor(e){super(e)}getDownloadUrl(e){return`/admin/page/downloadassets/id/${e}`}getDownloadTemplateUrl(e){return`/admin/pagetemplate/downloadassets/id/${e}`}getUploadUrl(e){return`/admin/page/uploadassets/id/${e}`}getUploadTemplateUrl(e){return`/admin/pagetemplate/uploadassets/id/${e}`}async downloadPageAssets(e,t){return this.axios.get(this.getDownloadUrl(e),{withCredentials:!0,headers:Object.assign({},t,{accept:"*/*"}),responseType:"arraybuffer"}).then(e=>e.data)}async uploadPageAssets(e,t,n){const a=new(await this.formdataModulePromise).FormData,{File:s}=await this.formdataModulePromise,o=new s([t],"file.zip",{type:"application/zip"});a.append("file",o);const i=this.getUploadUrl(e);return this.axios.post(i,a,{withCredentials:!0,headers:Object.assign({},n,{accept:"application/json","Content-Type":"multipart/form-data",Referer:this.axios.getUri({url:i})})}).then(e=>e.data)}async downloadTemplateAssets(e,t){return this.axios.get(this.getDownloadTemplateUrl(e),{withCredentials:!0,headers:Object.assign({},t,{accept:"*/*"}),responseType:"arraybuffer"}).then(e=>e.data)}async uploadTemplateAssets(e,t,n){const a=new(await this.formdataModulePromise).FormData,{File:s}=await this.formdataModulePromise,o=new s([t],"file.zip",{type:"application/zip"});a.append("file",o,"file.zip");const i=this.getUploadTemplateUrl(e);return this.axios.post(i,a,{withCredentials:!0,headers:Object.assign({},n,{accept:"application/json","Content-Type":"multipart/form-data",Referer:this.axios.getUri({url:i})})}).then(e=>e.data)}}class he extends ue{getDownloadUrl(e){return`/api/page/id/${e}/asset/download`}getDownloadTemplateUrl(e){return`/api/page_template/id/${e}/asset/download`}getUploadUrl(e){return`/api/page/id/${e}/asset/upload`}getUploadTemplateUrl(e){return`/api/page_template/id/${e}/asset/upload`}}const me="Something went wrong when fetching page data",ge="Something went wrong when fetching page template data",fe=" The instance may be down or in maintenance mode. Please try again later.";function ve(){return new Error(`${ge}.${fe}`)}function be(e){return e instanceof Error&&(e.message.includes(me)||e.message.includes(ge))}class ye extends pe{async getAll(e){return(await this.axios.get("/api/page",{withCredentials:!0,headers:Object.assign({},e,{Accept:"application/json","Content-Type":"application/json","Cache-Control":"no-cache",Pragma:"no-cache",Expires:"0"})})).data.pages}async get(e,t){const n=(await this.axios.get(`/api/page/id/${e}`,{withCredentials:!0,headers:Object.assign({},t,{accept:"application/json","content-type":"application/json"})})).data;if("object"!=typeof n||null===n)throw new Error(`${me}.${fe}`);return n.page}async getPageContent(e,t){return(await this.axios.get(`/p/${e}`,{withCredentials:!0,headers:Object.assign({},t,{accept:"text/html","content-type":"application/json"})})).data}async create(e,t){return(await this.axios.post("/api/page",e,{withCredentials:!0,headers:Object.assign({},t,{accept:"application/json","content-type":"application/json"})})).data.page}}class we extends pe{async getAll(e,t){const n=(await this.axios.get("/api/page_template",{withCredentials:!0,headers:Object.assign({},t,{Accept:"application/json","Content-Type":"application/json","Cache-Control":"no-cache",Pragma:"no-cache",Expires:"0"}),params:{internal_name:e}})).data;if("object"!=typeof n||null===n||!Array.isArray(n.page_templates))throw ve();return n.page_templates}async get(e,t){const n=(await this.axios.get(`/api/page_template/id/${e}`,{withCredentials:!0,headers:Object.assign({},t,{accept:"application/json","content-type":"application/json"})})).data;if("object"!=typeof n||null===n||"object"!=typeof n.page_template||null===n.page_template)throw ve();return n.page_template}}class xe extends pe{async getById(e,t){const n=(await this.axios.get("/api/page_variable",{withCredentials:!0,headers:Object.assign({},t,{accept:"application/json","content-type":"application/json"}),params:{page_id:e}})).data;return function(e){let t=e;if("string"==typeof e)try{t=JSON.parse(e)}catch{throw new TypeError('Page variable response contains invalid JSON in "tags".')}if(!Array.isArray(t))throw new TypeError('Page variable response must contain a "tags" array.');const n=t.findIndex(e=>{return!("object"==typeof(t=e)&&null!==t&&"string"==typeof t.name&&"string"==typeof t.value);var t});if(-1!==n)throw new TypeError(`Page variable response contains a malformed entry at index ${n}.`);return t}(n?.tags)}async updateById(e,t,n){await this.axios.put("/api/page_variable",{tags:JSON.stringify(t)},{withCredentials:!0,headers:Object.assign({},n,{accept:"application/json","content-type":"application/json"}),params:{page_id:e}})}}function ke(e){const t=e.response?.status||0,n=e.response?.data?.message||e.message||"Unknown error";switch(t){case 412:const e=n.toLowerCase();return e.includes("session expired")||e.includes("session has expired")?{status:t,message:n,code:"SESSION_EXPIRED",userFriendlyMessage:"Your session has expired",suggestions:["Refresh your token in the portal","Re-authenticate with the portal","Check if your token has been revoked","Ensure your token has the correct permissions"]}:{status:t,message:n,code:"AUTH_FAILED",userFriendlyMessage:"Authentication failed",suggestions:["Verify your token is correct","Check token permissions","Ensure the token hasn't expired","Try generating a new token"]};case 401:return{status:t,message:n,code:"UNAUTHORIZED",userFriendlyMessage:"Unauthorized access",suggestions:["Check if your token is valid","Verify you have the required permissions","Ensure the token hasn't been revoked","Try logging in again"]};case 403:return{status:t,message:n,code:"FORBIDDEN",userFriendlyMessage:"Access forbidden",suggestions:["Check your user permissions","Verify the portal page ID is correct","Ensure your token has the right scope","Contact your administrator"]};default:return{status:t,message:n,code:"UNKNOWN_ERROR",userFriendlyMessage:"An unexpected error occurred",suggestions:["Check your network connection","Verify the portal URL is correct","Try again in a few moments","Check the portal status"]}}}const Se="[DEV PAGE. DO NOT DELETE]",Ee=new Map;class Te{#e;#t;#n=null;#a;#s;#o;#i;#r;#l;#c;internalPageName;appId;templateLess;assetsApi;pageApi;pageTemplateApi;pageVariableApi;logger;constructor(e,t){const{headers:n={},appId:a,templateLess:s=!0,disableSSLValidation:o=!1,v7Features:r=!1,personalAccessToken:d}=t||{};this.#e=n,this.#s=[],this.#a="",this.#o=r,this.#i=d,this.#r=!1,this.#l=Promise.resolve(!1);const p=`${e}:${o}`;if(Ee.has(p))this.#t=Ee.get(p);else{o&&(process.env.NODE_TLS_REJECT_UNAUTHORIZED="0");const t=new c.Agent({keepAlive:!1,...o?{rejectUnauthorized:!1}:{}});this.#t=i.create({baseURL:e,headers:n,timeout:3e4,maxRedirects:5,httpAgent:new l.Agent({keepAlive:!1}),httpsAgent:t}),o&&(i.defaults.httpsAgent=t),Ee.set(p,this.#t)}this.appId=a,this.templateLess=s,this.assetsApi=new(r?he:ue)(this.#t),this.pageApi=new ye(this.#t),this.pageTemplateApi=new we(this.#t),this.pageVariableApi=new xe(this.#t),this.logger=ae()}async isTemplateLoaded(){return this.#l}get isV710OrHigher(){return this.#r}get v7Features(){return this.#o}#d(e){const t=Object.assign({},e,{host:void 0,referer:void 0});return Object.keys(t).forEach(e=>void 0===t[e]&&delete t[e]),this.#e=t,!t.authorization&&this.#i&&(t.authorization=`Bearer ${this.#i}`),t}#p(e){return e.replace(/[.*+?^${}()|[\]\\\/]/g,"\\$&")}get personalAccessToken(){return this.#i}set personalAccessToken(e){this.#i=e}updateHeaders(e){this.#d(e)}get localTemplateHTML(){return'<!DOCTYPE html>\n <html lang="en">\n <head>\n <meta charset="UTF-8" />\n <title>%%PAGE TITLE%%</title>\n <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />\n <meta name="theme-color" content="rgba(255, 255, 255, 1)" />\n <link rel="shortcut icon" type="image/x-icon" sizes="any" href="/img/favicon/favicon.ico" />\n <link rel="icon" type="image/png" sizes="16x16" href="/img/favicon/favicon-16x16.png" />\n <link rel="icon" type="image/png" sizes="32x32" href="/img/favicon/favicon-32x32.png" />\n <link rel="icon" type="image/png" sizes="48x48" href="/img/favicon/favicon-48x48.png" />\n <meta name="msapplication-config" content="/auth/browserconfig.xml" />\n <link rel="apple-touch-icon" sizes="180x180" href="/img/favicon/apple-touch-icon.png" />\n <link rel="manifest" href="/auth/site.webmanifest" />\n <link rel="manifest" href="/auth/manifest.json" />\n <link rel="stylesheet" type="text/css" href="/auth/theme-vars.css" />\n <link rel="icon" type="image/svg+xml" href="/favicon.ico" />\n <meta name="viewport" content="width=device-width, initial-scale=1.0" />\n <script src="/js/libs/underscore-latest.min.js" charset="utf-8"><\/script>\n <script src="/js/jquery/jquery-latest.min.js" charset="utf-8"><\/script>\n <script src="/js/application/rating_component.js" charset="utf-8"><\/script>\n </head>\n <body>\n <div id="mi-react-root"></div>\n <script src="/auth/info.js"><\/script>\n <script src="/js/main.js" defer><\/script>\n <link rel="stylesheet" href="/css/main.css" />\n </body>\n </html>'}async getPageTemplate(e){if(await this.#l||this.#n||(this.#l=new Promise(e=>{this.#c=e})),this.#n)return Promise.resolve(this.#n);if(void 0===this.templateLess&&(this.templateLess=!1),this.#o){const t=await this.pageApi.get(this.appId,this.#d(e)).then(e=>(this.logger.info(se.green("Page fetched")),e)).catch(async t=>{if(this.#c(!1),be(t))throw t;throw await this.pageApi.checkAuth(this.#d(e))?(this.logger.error(se.red(`Error fetching page data: ${t.message}\n${t.stack}`)),new Error("The current user does not have access to this page. Check your configuration to ensure app.id is correct.")):t});return void 0!==t.template_id&&(this.#r=!0),this.internalPageName=t.internal_name,this.#n=this.localTemplateHTML,this.#n=this.#n.replace(/%%PAGE TITLE%%/g,t.name||"Local template"),this.logger.info(se.green("Local page template fetched")),this.#c(!0),this.#n}let t=(await this.pageApi.getAll(this.#d(e)).then(e=>(this.logger.info(se.green("Page list fetched")),e)).catch(async t=>{throw this.#c(!1),await this.pageApi.checkAuth(this.#d(e))?(this.logger.error(se.red(`Error fetching page list: ${t.message}\n${t.stack}`)),new Error("Current user does not have access to page list")):t})).find(e=>e.name===Se);return t||(this.logger.warn(se.yellow("Creating dev page template...")),t=await this.pageApi.create({enabled:"Y",name:Se,internal_name:"dev-page-template",visible_in_homepage:"Y"},this.#d(e)).then(e=>(this.logger.info(se.green("Dev page created")),e)).catch(e=>{throw this.logger.error(se.red(`Error creating dev page: ${e.message}`)),this.#c(!1),new Error(`Error when creating dev page.\n That can be caused by missing permissions or page with name "${Se}" already exists`)})),await this.pageApi.getPageContent(t.internal_name,this.#d(e)).then(e=>(this.#n=e,e)).then(e=>(this.#c(!0),this.logger.info(se.green("Page template fetched")),e)).catch(e=>{throw this.#c(!1),this.logger.error(se.red(`Error fetching page template: ${e.message}`)),new Error("Error fetching page template")})}async getPageVariables(e,t){return void 0===this.appId&&(this.appId=e,this.templateLess=!1),this.#n=await this.getPageTemplate(t),await this.pageApi.get(e,this.#d(t)).then(e=>{const{tags:t="[]",name:n,template:a}=e;if(a&&t){const e=JSON.parse(t);return this.#s=e,this.#a=n,e}return this.#s=[],this.#a=n,[]}).catch(t=>{if(this.logger.error(se.red(`Error fetching page variables: ${t.message}`)),404===t.response?.status)throw new Error(`Portal Page with id "${e}" not found on instance ${this.#t.getUri()}`);if(401===t.response?.status)throw this.logger.warn(`Unauthorized error: ${t}`),new Error(`Current user does not have access to page with id "${e}" on instance ${this.#t.getUri()}`);throw t})}async getLivePageVariables(e=this.#e){if(void 0===this.appId)throw new Error("Cannot fetch live page variables before the page ID is known");const t=this.appId,n=Date.now();this.logger.info(se.cyan(`[page-variables] Fetching live variables for page ID ${t} (page_id lookup)`));const a=await this.pageVariableApi.getById(t,this.#d(e));return this.logger.info(se.green(`[page-variables] Fetched ${a.length} live variable(s) for page ID ${t} in ${Date.now()-n}ms`)),a}async applyPageVariables(e,t=this.#e){if(void 0===this.appId)throw new Error("Cannot apply page variables before the page ID is known");const n=this.appId,a=Date.now();this.logger.info(se.cyan(`[page-variables] Applying ${e.length} variable(s) to page ID ${n} (page_id lookup)`)),await this.pageVariableApi.updateById(n,e,this.#d(t)),this.logger.info(se.green(`[page-variables] Applied variables to page ID ${n} in ${Date.now()-a}ms`))}get isTemplateLess(){return!!this.templateLess}async reloadPageVariables(e=this.#e){return this.templateLess||void 0===this.appId?null:this.getPageVariables(this.appId,e)}async getPageInfo(e,t){void 0===this.appId&&(this.appId=e);const n=this.#d(t),a=this.appId;try{return this.#o?await this.#u(a,n):await this.#h(a,n)}catch(e){this.#m(e,a)}}async#u(e,t){const n=await this.pageApi.get(e,t);return this.logger.info(se.green("Page fetched")),void 0!==n.template_id&&(this.#r=!0),this.internalPageName=n.internal_name,n}async#h(e,t){return await this.pageApi.get(e,t)}#m(e,t){if(be(e))throw e;if(this.#o&&e.message?.includes("access"))throw new Error("The current user does not have access to this page. Check your configuration to ensure app.id is correct.");if(404===e.response?.status)throw new Error(`Portal Page with id "${t}" not found on instance ${this.#t.getUri()}`);throw 401===e.response?.status&&this.logger.error(se.red(`Current user does not have access to page with id "${t}" on instance ${this.#t.getUri()}`)),e}buildPage(e,t=!1){let n="string"==typeof e?e:e.toString("utf-8");for(const e of this.#s){const t=this.#p(e.name),a=new RegExp(`\\[${t}\\]`,"g");n=n.replace(a,()=>e.value)}const a=new d(t?n:this.#n),s="%%PLACEHOLDER%%";if(!t){const e=a.window.document.createElement("div");e.innerHTML=s;const t=a.window.document.querySelector(".main-side");if(t){const n=t.querySelectorAll("script");n.length?t.insertBefore(e,n.item(n.length-1)):t.append(e)}else{const t=a.window.document.createElement("div");t.append(e),a.window.document.body.append(t)}const n=a.window.document.querySelector("head"),o=n.querySelector("title");if(o)o.text=this.#a;else{const e=a.window.document.createElement("title");e.textContent=this.#a,n.appendChild(e)}}return a.serialize().replace(new RegExp(`<div>\\s*${s}\\s*<\\/div>`,"i"),n)}async getAssets(){if(this.appId){if(this.templateLess)return await this.assetsApi.downloadPageAssets(this.appId,this.#e);{const e=await this.pageApi.get(this.appId,this.#e);if(this.#r){const t=await this.pageTemplateApi.get(e.template_id,this.#e);return await this.assetsApi.downloadTemplateAssets(t.id,this.#e)}if(e.template)return await this.assetsApi.downloadTemplateAssets(e.template,this.#e)}}}async updateAssets(e){if(this.appId){if(this.templateLess)return await this.assetsApi.uploadPageAssets(this.appId,e,this.#e);{const t=await this.pageApi.get(this.appId,this.#e);if(this.#r){const n=await this.pageTemplateApi.get(t.template_id,this.#e);return await this.assetsApi.uploadTemplateAssets(n.id,e,this.#e)}if(t.template)return await this.assetsApi.uploadTemplateAssets(t.template,e,this.#e)}}}async validateCredentials(e){try{const t=e||this.#e;return await this.get("/api/user",t,!0),{isValid:!0}}catch(e){const t=ke(e);return{isValid:!1,error:t.userFriendlyMessage,code:t.code}}}async get(e,t,n=!1){const a=n?t:Object.assign({},this.#d(this.#e),t);try{return await this.#t.get(e,{headers:a})}catch(e){if("UNKNOWN_ERROR"!==ke(e).code){const t=ke(e);throw this.logger.error(se.red(`API request failed: ${t.userFriendlyMessage}`)),function(e,t,n){const a=ke(t),s=`[${n}] `;e.error(se.red(`${s}${a.userFriendlyMessage}`)),e.error(se.red(`Status: ${a.status} (${a.code})`)),e.error(se.red(`Details: ${a.message}`)),a.suggestions.length>0&&(e.info(se.yellow("Suggestions:")),a.suggestions.forEach((t,n)=>{e.info(se.yellow(` ${n+1}. ${t}`))}))}(this.logger,e,"API Request"),e.response||(e.response={status:t.status,data:{message:t.message}}),e.tokenErrorInfo=t,e}throw e}}}function Ie(e,t){if(!e||e.includes("\0")||$.posix.isAbsolute(e)||$.win32.isAbsolute(e)||$.win32.parse(e).root)throw new Error(`[pp-dev] ${t} must be a relative path inside its output directory.`);const n=$.posix.normalize(e.replace(/\\/g,"/"));if("."===n||".."===n||n.startsWith("../"))throw new Error(`[pp-dev] ${t} must stay inside its output directory.`);return n}function Ae(e){return Ie(`${e.replace(/^@/,"").replace(/[\\/]+/g,"-")}.zip`,"default ZIP output file name")}function Re(e,t,n){const a=Ie(t,n),s=$.resolve(e),o=$.resolve(s,...a.split("/")),i=$.relative(s,o);if(".."===i||i.startsWith(`..${$.sep}`)||$.isAbsolute(i))throw new Error(`[pp-dev] ${n} must resolve inside its output directory.`);return o}const $e="BUILD-MANIFEST.json";function Oe(e,t,n=new Set){const a=y(e,{withFileTypes:!0}),s=[];for(const o of a){const a=u.join(e,o.name),i=u.relative(t,a).replace(/\\/g,"/");o.isDirectory()?s.push(...Oe(a,t,n)):o.isFile()&&!n.has(i)&&s.push(a)}return s}function Be(e){const t=m(e);return k("sha256").update(t).digest("hex")}function Ce(e){return e.replace(/^([a-z][a-z\d+.-]*:\/\/)(?:[^/?#@]+(?::[^/?#@]*)?@)/i,"$1")}function _e(e){const t=e.trim().replace(/^git\+/,"").replace(/\.git$/i,""),n=t.match(/^([^@]+)@([^:]+):(.+)$/);if(n){const[,,e,t]=n;return Ce(`https://${e}/${t.replace(/^\/+/,"")}`)}const a=t.match(/^ssh:\/\/(?:[^@]+@)?([^/:]+)(?::\d+)?\/(.+)$/i);if(a){const[,e,t]=a;return Ce(`https://${e}/${t.replace(/^\/+/,"")}`)}const s=t.match(/^git:\/\/([^/]+)\/(.+)$/i);if(s){const[,e,t]=s;return Ce(`https://${e}/${t.replace(/^\/+/,"")}`)}return Ce(t)}function je(e){if(!1===e.enabled)return;const{outDir:t,root:a=process.cwd(),versionFileTemplate:s,packageVersion:o}=e,i=u.resolve(a,t);if(!f(i,{throwIfNoEntry:!1})?.isDirectory())return;const r=(new Date).toISOString(),l=s??"VERSION-v{packageversion}-{currentDate}.json",c=Ie(function(e,t,n){return e.replace(/\{packageversion\}/g,t).replace(/\{currentDate\}/g,n)}(l,o,r.replace(/:/g,"-")),"VERSION output file name"),d=Re(i,c,"VERSION output file name"),p=Oe(i,i,new Set([c,$e])),h={};for(const e of p){h[u.relative(i,e).replace(/\\/g,"/")]=Be(e)}const g=function(e){const t=Object.keys(e).sort().map(t=>e[t]).join("");return k("sha256").update(t).digest("hex")}(h),y=function(e){const t=Object.keys(e).sort().map(t=>`${t}:${e[t]}`).join("\n");return k("sha256").update(t,"utf-8").digest("hex")}(h),w=function(){const e=n(import.meta.url);let t=u.dirname(e);for(;;){const e=u.join(t,"package.json"),n=f(e,{throwIfNoEntry:!1});if(n?.isFile())try{const t=JSON.parse(m(e,"utf-8"));if("string"==typeof t.version)return t.version}catch{}const a=u.dirname(t);if(a===t)return"unknown";t=a}}(),x=function(e){if("string"==typeof e&&e.trim())return e.trim();const t=["GITHUB_HEAD_REF","GITHUB_REF_NAME","CI_COMMIT_REF_NAME","BITBUCKET_BRANCH","BUILD_SOURCEBRANCHNAME","BRANCH_NAME","TRAVIS_BRANCH","CIRCLE_BRANCH","BUILDKITE_BRANCH","VERCEL_GIT_COMMIT_REF"];for(const e of t){const t=process.env[e];if("string"==typeof t&&t.trim())return t.trim()}try{const e=I("git rev-parse --abbrev-ref HEAD",{encoding:"utf8",stdio:["ignore","pipe","ignore"]}).trim();if(e&&"HEAD"!==e)return e}catch{}}(e.packageBranchName),S={schemaVersion:1,version:`v${o}`,date:r,checksum:g,files:h,repositoryUrl:e.packageRepositoryUrl?_e(e.packageRepositoryUrl):void 0,branchName:x,helperVersion:w},E=u.join(i,$e),T={schemaVersion:1,manifestType:"pp-dev-build-manifest",generatedAt:r,versionFile:c,versionFileTemplate:l,versionFileSchemaVersion:1,buildFingerprint:y,compat:{versionFileRequired:!0}};v(u.dirname(d),{recursive:!0}),b(d,JSON.stringify(S,null,2),"utf-8"),b(E,JSON.stringify(T,null,2),"utf-8")}async function Ne(e){const t=new O,n=async a=>{const s=await w.readdir(a,{withFileTypes:!0});for(const o of s){const s=p.join(a,o.name);if(o.isDirectory())await n(s);else if(o.isFile()){const n=p.relative(e,s).replace(/\\/g,"/");t.file(n,await w.readFile(s))}}};return await n(e),await t.generateAsync({type:"nodebuffer",compression:"DEFLATE"})}function He(e){return new Promise((t,n)=>{let a;try{const t=B(p.join(e,"package.json")).resolve("next/package.json");a=p.resolve(p.dirname(t),"dist/bin/next")}catch{return void n(new Error(`Unable to resolve the "next" binary from ${e}. Is Next.js installed?`))}const s=T.spawn(E.execPath,[a,"build"],{cwd:e,env:Object.assign({},E.env,{NODE_ENV:"production",[Q]:"1"}),stdio:"inherit"});s.on("close",e=>{0===e?t():n(new Error(`next build exited with code ${e}`))}),s.on("error",n)})}const Fe="pageName",Le="date",Me=p.dirname("undefined"!=typeof __filename&&__filename||n(import.meta.url)),Pe=p.resolve(Me,"..",".."),De=p.resolve(Pe,"..",".."),Ve=p.resolve(De,".pp-dev-meta"),qe=p.resolve(Ve,"sync-service.meta.json"),ze="BUILD-MANIFEST.json",Ue="__template_variables.json";class Je{root;backupFolder;backupNameTemplate;dateFormat;pageName;currentMeta=null;distZipFolder;distZipFilename;versionFileTemplate;buildInputFolder;nextBuild;logger;constructor(e,t){this.pageName=e;const{root:n=E.cwd(),backupFolder:a="backups",distZipFolder:s=p.resolve(n,"dist-zip"),distZipFilename:o=Ae(this.pageName),backupNameTemplate:i=`{${Fe}}-{${Le}}.zip`,versionFileTemplate:r="VERSION-v{packageversion}-{currentDate}.json",buildInputFolder:l,dateFormat:c=e=>e.toISOString().replace(/:/g,"-").replace(/\..*$/,""),nextBuild:d}=t||{};this.root=n,this.backupFolder=p.resolve(n,a),this.backupNameTemplate=i,this.dateFormat=c,this.distZipFolder=s,this.distZipFilename=Ie(o,"ZIP output file name"),this.versionFileTemplate=Ie(r,"VERSION file name template"),this.buildInputFolder=l,this.nextBuild=d,this.syncMeta(),this.logger=ae()}async checkMeta(){try{await w.stat(Ve)}catch{await w.mkdir(Ve)}try{await w.stat(this.backupFolder)}catch{await w.mkdir(this.backupFolder)}}async readMetaFile(){return await this.checkMeta(),await w.readFile(qe,{encoding:"utf-8"}).catch(()=>"{}")}async writeMetaFile(e){return await this.checkMeta(),await w.writeFile(qe,JSON.stringify(e,null,2),{encoding:"utf-8"})}async syncMeta(){this.currentMeta?await this.writeMetaFile(this.currentMeta):this.currentMeta=JSON.parse(await this.readMetaFile())}async getLatestSavedBackup(){const{lastBackupName:e}=this.currentMeta;if(!e)return null;const t=p.resolve(this.backupFolder,e);try{return await w.stat(t),t}catch{return null}}backupName(e,t=new Date){return this.backupNameTemplate.replace(`{${Fe}}`,e).replace(`{${Le}}`,this.dateFormat(t))}getBackupMeta(){return this.currentMeta}pathToPosix(e){return e.replace(/\\/g,"/")}normalizeBackupManifestPath(e){try{return Ie(e,"VERSION manifest file path")}catch{throw new Error(`VERSION manifest file path "${e}" resolves outside the backup root`)}}async normalizeExtractedRootDir(e){let t=e;for(;;){const e=await w.readdir(t,{withFileTypes:!0});if(1!==e.length||!e[0]?.isDirectory())return t;t=p.join(t,e[0].name)}}async listFilesRecursive(e){const t=await w.readdir(e,{withFileTypes:!0}),n=[];for(const a of t){const t=p.join(e,a.name);a.isDirectory()?n.push(...await this.listFilesRecursive(t)):a.isFile()&&n.push(t)}return n}buildManifestChecksum(e){const t=Object.keys(e).sort().map(t=>e[t]).join("");return x.createHash("sha256").update(t).digest("hex")}buildBackupFingerprint(e){const t=Object.keys(e).sort().map(t=>`${t}:${e[t]}`).join("\n");return x.createHash("sha256").update(t,"utf-8").digest("hex")}escapeRegExp(e){return e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}versionFileTemplateMatcher(){const e=/\{[^}]+\}/g,t=[];let n=0,a=null;for(;a=e.exec(this.versionFileTemplate),a;){const[e]=a,s=this.versionFileTemplate.slice(n,a.index);t.push(this.escapeRegExp(s)),t.push("[^/]+"),n=a.index+e.length}const s=this.versionFileTemplate.slice(n);return t.push(this.escapeRegExp(s)),new RegExp(`^${t.join("")}$`,"i")}isTemplateVariablesBasename(e){return e===Ue}async pickTemplateVariablesFromBackupZip(e,t){const n=e.find(e=>p.basename(e)===Ue);if(!n)return null;const a=p.join(t,n),s=await w.readFile(a);return{relativePath:n,content:s,actualHash:x.createHash("sha256").update(s).digest("hex"),manifestHash:null}}async resolveBackupAnalysisFromZip(e){const t=await w.mkdtemp(p.join(A.tmpdir(),"pp-dev-backup-")),n=p.join(t,"backup.zip"),a=p.join(t,"extracted");try{await w.mkdir(a,{recursive:!0}),await w.writeFile(n,e),await R(n,{dir:a});const t=await this.normalizeExtractedRootDir(a),s=(await this.listFilesRecursive(t)).map(e=>this.pathToPosix(p.relative(t,e))),o=s.filter(e=>p.basename(e)===ze);if(o.length>1)throw new Error(`Expected at most one ${ze} in backup ZIP, found ${o.length}`);const i=1===o.length?o[0]:null;let r=null;if(i){const e=p.join(t,i);if(r=JSON.parse(await w.readFile(e,"utf-8")),!(r&&"pp-dev-build-manifest"===r.manifestType&&"string"==typeof r.versionFile&&r.versionFile.trim()&&"string"==typeof r.versionFileTemplate&&r.versionFileTemplate.trim()&&"string"==typeof r.buildFingerprint&&r.buildFingerprint.trim()))throw new Error("Build manifest is missing required fields")}let l=null;if(r){const e=this.pathToPosix(r.versionFile.trim()),t=s.filter(t=>t===e),n=s.filter(t=>p.basename(t)===p.basename(e)),a=t.length>0?t:n;if(a.length>1)throw new Error(`Build manifest version file "${r.versionFile}" must resolve to exactly one file in backup ZIP, found ${a.length}`);if(1===a.length&&(l=a[0]),!0===r.compat?.versionFileRequired&&null===l)throw new Error(`Build manifest requires version file "${r.versionFile}", but it is missing in backup ZIP`)}else{const e=this.versionFileTemplateMatcher(),t=s.filter(t=>e.test(p.basename(t)));if(t.length>1)throw new Error(`Expected exactly one version file matching template "${this.versionFileTemplate}" in backup ZIP, found ${t.length}`);1===t.length&&(l=t[0])}const c={};let d=null,u=null;const h={};let m=[];if(l){const e=p.join(t,l),n=JSON.parse(await w.readFile(e,"utf-8"));if(!n?.files||"object"!=typeof n.files)throw new Error("VERSION manifest files map is missing or invalid");if("string"!=typeof n.checksum||!n.checksum)throw new Error("VERSION manifest checksum is missing or invalid");u=n.checksum;for(const[e,t]of Object.entries(n.files)){if("string"!=typeof t||!t.trim())throw new Error(`VERSION manifest contains invalid hash for file: ${e}`);const n=this.normalizeBackupManifestPath(e);if(Object.prototype.hasOwnProperty.call(c,n))throw new Error(`VERSION manifest contains duplicate normalized file path: ${n}`);c[n]=t}if(this.buildManifestChecksum(c)!==n.checksum)throw new Error("VERSION manifest checksum does not match its files map");for(const[e,n]of Object.entries(c)){const a=p.join(t,e),s=await w.readFile(a).catch(()=>null);if(!s)throw new Error(`VERSION manifest references missing file: ${e}`);const o=x.createHash("sha256").update(s).digest("hex");h[e]=o,o!==n&&m.push({relativePath:e,expected:n,actual:o}),this.isTemplateVariablesBasename(p.basename(e))&&(d={relativePath:e,content:s,actualHash:o,manifestHash:n})}m.length>0&&this.logger.warn(se.yellow(`[DistService] VERSION manifest hash mismatch for ${m.length} file(s) (confirm in UI): ${m.map(e=>e.relativePath).join(", ")}`))}const g=new Set(m.map(e=>e.relativePath)),f={},v=[];for(const e of s){if(e===i)continue;if(l&&e===l)continue;if(l&&Object.prototype.hasOwnProperty.call(f,e))continue;const n=p.join(t,e),a=await w.readFile(n),s=x.createHash("sha256").update(a).digest("hex");l&&Object.prototype.hasOwnProperty.call(c,e)?f[e]=g.has(e)?h[e]:c[e]:l?(f[e]="__NOT_IN_VERSION_FILE__",v.push(e)):f[e]=s}d||(d=await this.pickTemplateVariablesFromBackupZip(s,t));const b=this.buildBackupFingerprint(f);let y=null;return r&&l&&b!==r.buildFingerprint&&(y={expected:r.buildFingerprint,actual:b},this.logger.warn(se.yellow(`[DistService] Build manifest fingerprint mismatch (confirm in UI): expected ${r.buildFingerprint}, got ${b}`))),{backupFingerprint:b,unknownFiles:v,templateVariables:d,mainHash:u,allFilesInMeta:Boolean(l&&0===v.length),buildManifestMismatch:y,versionManifestHashMismatches:m}}finally{await w.rm(t,{recursive:!0,force:!0})}}async analyzeBackup(e){if("PK"!==e.toString("utf-8").slice(0,4))throw new Error("Backup file is not a ZIP file");return await this.resolveBackupAnalysisFromZip(e)}async getPublicTemplateVariablesHash(){const e=p.resolve(this.root,"public",Ue),t=await w.readFile(e).catch(()=>null);return t?x.createHash("sha256").update(t).digest("hex"):null}async readPublicTemplateVariablesFile(){const e=p.resolve(this.root,"public",Ue);return await w.readFile(e).catch(()=>null)}async saveTemplateVariablesFile(e){const t=p.resolve(this.root,"public",Ue);return await w.mkdir(p.dirname(t),{recursive:!0}),await w.writeFile(t,e),t}async saveBackup(e,t){const n=t??await this.analyzeBackup(e),a=n.backupFingerprint;if(await this.getLatestSavedBackup()){const{lastBackupHash:e,lastMainHash:t}=this.currentMeta;if(e===a)return void this.logger.info(se.yellow(`[DistService] Backup skipped: same backup fingerprint as last saved (${a.slice(0,12)}...)`));if(n.allFilesInMeta&&n.mainHash&&t===n.mainHash)return void this.logger.info(se.yellow("[DistService] Backup skipped: same VERSION manifest checksum (main hash) and all files listed in VERSION"))}const s=new Date,o=this.backupName(this.pageName,s);return this.currentMeta.lastBackupName=o,this.currentMeta.lastBackupHash=a,this.currentMeta.lastBackupDate=s.toISOString(),n.mainHash?this.currentMeta.lastMainHash=n.mainHash:delete this.currentMeta.lastMainHash,await this.syncMeta(),await w.writeFile(p.resolve(this.backupFolder,o),e).finally(()=>{this.logger.info(`Backup saved to ${o}`)})}async buildNewAssets(){if(this.nextBuild)return await this.buildNextAssets();const e=new Promise((e,t)=>{let n="";this.logger.info(se.cyan("[DistService] Build started"));const a=T.spawn("node",[p.resolve(Pe,"./bin/pp-dev.js"),"build"],{cwd:this.root,env:Object.assign({},E.env,{NODE_ENV:"production"}),stdio:"inherit"});a.on("message",e=>{n+=e}),a.on("close",a=>{0===a?e(n):t(new Error(`build command exited with code ${a}`))}),a.on("error",e=>{t(e)})});try{if(await e.finally(()=>{this.logger.info(se.cyan("[DistService] Build finished"))}),this.buildInputFolder){const e=p.resolve(this.root,this.buildInputFolder);if(!await this.isDirectory(e))throw new Error(`Build input directory ${e} not found`);return await Ne(e)}const t=p.resolve(this.root,this.distZipFolder,this.distZipFilename);if(!await w.stat(t))throw new Error(`File ${t} not found`);return await w.readFile(t)}catch(e){throw this.logger.error(e instanceof Error?e.stack??e.message:String(e)),e}}async saveBackupAndBuild(e){return await this.saveBackup(e),await this.buildNewAssets()}async buildNextAssets(){const{projectRoot:e,distDir:t}=this.nextBuild;this.logger.info(se.cyan("[DistService] Next.js build started")),await He(e),this.logger.info(se.cyan("[DistService] Next.js build finished"));const n=p.resolve(e,t);if(!await this.isDirectory(n))throw new Error(`Next.js build output directory not found: ${n}`);return je({outDir:n,packageVersion:this.nextBuild.packageVersion,versionFileTemplate:this.versionFileTemplate,packageRepositoryUrl:this.nextBuild.packageRepositoryUrl,packageBranchName:this.nextBuild.packageBranchName}),await Ne(n)}async isDirectory(e){try{return(await w.stat(e)).isDirectory()}catch{return!1}}}class We{server;opts;eventMap;logger;syncActionResolvers=new Map;constructor(e,t){this.server=e,this.opts=t||{},this.eventMap=new Map,this.eventMap.set("info-data:request",this.onInfoDataRequest.bind(this)),this.eventMap.set("template:sync",this.onTemplateSync.bind(this)),this.eventMap.set("template:sync:action-response",this.onTemplateSyncActionResponse.bind(this)),this.eventMap.set("page-variables:reload",this.onPageVariablesReload.bind(this)),this.logger=ae(),this.init()}init(){const{ws:e}=this.server;for(const[t,n]of this.eventMap)e.on(t,n);e.on("close",()=>{this.clearAllPendingSyncActions(!1)}),e.on("error",()=>{this.clearAllPendingSyncActions(!1)})}resolveSyncAction(e,t){const n=this.syncActionResolvers.get(e);n&&(clearTimeout(n.timeoutId),this.syncActionResolvers.delete(e),n.resolve(t))}clearAllPendingSyncActions(e){for(const t of[...this.syncActionResolvers.keys()])this.resolveSyncAction(t,e)}onInfoDataRequest(e,t){t.send("info-data:response",{})}onTemplateSyncActionResponse(e){e&&"string"==typeof e.requestId&&"boolean"==typeof e.approved&&this.resolveSyncAction(e.requestId,e.approved)}async requestSyncAction(e,t){const n=S(),a=this.opts.syncActionTimeoutMs??12e4;return await new Promise(s=>{const o=setTimeout(()=>{this.resolveSyncAction(n,!1)},a);this.syncActionResolvers.set(n,{resolve:s,timeoutId:o}),t.send("template:sync:action-required",{...e,requestId:n})})}async onTemplateSync(e,t){if(!this.opts.distService||!this.opts.miAPI)return t.send("template:sync:response",{error:"Dist service or MiAPI is not defined"}),void this.logger.error(se.red("Dist service or MiAPI is not defined"));{const{distService:e,miAPI:n}=this.opts;try{if(this.server.config.clientInjectionPlugin?.v7Features){if(!n?.isV710OrHigher)return void t.send("template:sync:response",{error:"This feature is available only for MI v7.1.0 or higher",config:{canSync:!1}});t.send("client:config:update",{config:{canSync:!0}})}const a=await(n?.getAssets().catch(async e=>{if(r(e)){if("SESSION_EXPIRED"===ke(e).code){this.logger.info(se.yellow("Session expired - attempting to validate credentials"));try{const e=await(n?.validateCredentials());e&&!e.isValid?(this.logger.error(se.red(`Authentication error: ${e.error}`)),t.send("template:sync:response",{error:e.error,code:e.code,refresh:!0})):(this.logger.info(se.yellow("Session expired")),t.send("template:sync:response",{error:"Session expired",code:"SESSION_EXPIRED",refresh:!0}))}catch(e){this.logger.info(se.yellow("Session expired")),t.send("template:sync:response",{error:"Session expired",code:"SESSION_EXPIRED",refresh:!0})}return e}if(e.cause instanceof Error&&("ECONNRESET"===e.cause.code||"ENOTFOUND"===e.cause.code))return this.logger.info(se.yellow("Server in maintenance mode, VPN connection is needed or no internet connection")),t.send("template:sync:response",{error:"Server in maintenance mode, VPN connection is needed or no internet connection",code:"CONNECTION_ERROR"}),e;const a=e.response?.status;if(502===a||503===a||504===a)return this.logger.info(se.yellow(`Server in maintenance mode or unreachable (HTTP ${a}); VPN may be required`)),t.send("template:sync:response",{error:"Server in maintenance mode, VPN connection is needed or no internet connection",code:"CONNECTION_ERROR"}),e}if(be(e))return this.logger.info(se.yellow("Server in maintenance mode or non-JSON page / page-template API response (unavailable HTML body)")),t.send("template:sync:response",{error:"Server in maintenance mode, VPN connection is needed or no internet connection",code:"CONNECTION_ERROR"}),e;throw e}));if(a instanceof Error)return;let s;if(a){const n=await(e?.analyzeBackup(a).catch(e=>"Backup file is not a ZIP file"===e.message?(this.logger.error(se.red("Backup file is not a ZIP file")),e):e));if(n instanceof Error)s=n;else if(n){await(e?.saveBackup(a,n));const o=0===n.unknownFiles.length&&1===n.versionManifestHashMismatches.length&&p.basename(n.versionManifestHashMismatches[0].relativePath)===Ue,i=n.versionManifestHashMismatches,r=i.filter(e=>p.basename(e.relativePath)===Ue),l=i.filter(e=>p.basename(e.relativePath)!==Ue),c=!o&&r.length>0&&l.length>0,d=o?[]:l.map(e=>e.relativePath),u=async()=>{if(!n.templateVariables||!e)return;const{templateVariables:a}=n;if(await e.getPublicTemplateVariablesHash()!==a.actualHash){await this.requestSyncAction({title:"Template variables (server backup)",content:"The server backup includes __template_variables.json. It differs from your local public/__template_variables.json (or that file is missing). Replace your project copy with the server backup?",confirmText:"Replace from server",cancelText:"Keep local"},t)&&await e.saveTemplateVariablesFile(a.content)}};if(n.unknownFiles.length>0){if(!await this.requestSyncAction({title:"Unknown files found in backup",content:`Backup contains files not listed in VERSION: ${n.unknownFiles.join(", ")}. Continue sync or cancel?`,confirmText:"Continue sync",cancelText:"Cancel sync"},t))return t.send("template:sync:response",{cancelled:!0,message:"Sync cancelled by user. Backup was saved."}),void this.logger.info(se.yellow("Sync cancelled by user"))}if(c&&await u(),!o&&d.length>0){const e=40,n=d,a=n.slice(0,e),s=n.length-a.length,o=a.map(e=>`• ${e}`).join("\n"),i=s>0?`\n... and ${s} more file${1===s?"":"s"}`:"";if(!await this.requestSyncAction({title:"VERSION manifest out of date",content:`These files no longer match the hashes recorded in the VERSION manifest:\n\n${o}${i}\n\nCancel sync, or override and continue using the files on disk?`,confirmText:"Override and continue",cancelText:"Cancel sync"},t))return t.send("template:sync:response",{cancelled:!0,message:"Sync cancelled by user. Backup was saved."}),void this.logger.info(se.yellow("Sync cancelled by user"))}if(!o&&n.buildManifestMismatch&&0===n.versionManifestHashMismatches.length){const{expected:e,actual:a}=n.buildManifestMismatch;if(!await this.requestSyncAction({title:"Build manifest fingerprint mismatch",content:`The recomputed backup fingerprint does not match BUILD-MANIFEST.json.\nManifest: ${e.slice(0,12)}...\nComputed: ${a.slice(0,12)}...\nContinue sync anyway?`,confirmText:"Continue sync",cancelText:"Cancel sync"},t))return t.send("template:sync:response",{cancelled:!0,message:"Sync cancelled by user. Backup was saved."}),void this.logger.info(se.yellow("Sync cancelled by user"))}c||await u(),s=await(e?.buildNewAssets())}}else s=await(e?.buildNewAssets());if(!(s&&s instanceof Buffer))return s instanceof Error?(t.send("template:sync:response",{error:s.message}),void this.logger.error(se.red(s.message))):(t.send("template:sync:response",{error:"Failed to build new assets"}),void this.logger.error(se.red("Failed to build new assets")));{const a=await(n?.updateAssets(s));if("OK"===a?.status){const n=e?.getBackupMeta(),{lastBackupName:a,lastBackupHash:s,lastBackupDate:o}=n||{lastBackupName:"",lastBackupHash:"",lastBackupDate:(new Date).toISOString()};t.send("template:sync:response",{syncedAt:new Date(o),currentHash:s,backupFilename:a}),this.logger.info(se.green("Template synced"))}else t.send("template:sync:response",{error:"Failed to update assets"}),this.logger.error(se.red("Failed to update assets"))}}catch(e){const n=e instanceof Error?e.message:"Template sync failed";return this.logger.error(se.red(`Template sync failed: ${n}`)),void t.send("template:sync:response",{error:n,code:"SYNC_FAILED"})}}}async onPageVariablesReload(e,t){const{miAPI:n}=this.opts;if(!n)return t.send("page-variables:reload:response",{error:"MiAPI is not defined"}),void this.logger.error(se.red("MiAPI is not defined"));try{const e=await n.reloadPageVariables();t.send("page-variables:reload:response",{ok:!0,count:e?e.length:0,skipped:null===e}),this.logger.info(se.green("Page variables reloaded"))}catch(e){const n=e instanceof Error?e.message:"Reload variables failed";this.logger.error(se.red(`Reload variables failed: ${n}`)),t.send("page-variables:reload:response",{error:n})}}}const Ye=/\.(?:[a-z0-9]+)$/i,Ke=6e5,Ze=104857600,Ge=1e3;class Xe extends C.Cache{totalSize=0;maxSize;maxItems;constructor(e,t){super(),this.maxSize=e,this.maxItems=t}put(e,t,n){const a=this.get(e);a&&(this.totalSize-=a.size);const s=super.put(e,t,n);return this.totalSize+=t.size,this.cleanup(),s}cleanup(){const e=this.keys();if(e.length>this.maxItems){const t=e.length-this.maxItems,n=e.sort((e,t)=>{const n=this.get(e),a=this.get(t);return(n?.timestamp||0)-(a?.timestamp||0)});for(let e=0;e<t;e++){const t=n[e],a=this.get(t);a&&(this.totalSize-=a.size,this.del(t))}}for(;this.totalSize>this.maxSize;){const e=this.keys();if(0===e.length)break;const t=e.sort((e,t)=>{const n=this.get(e),a=this.get(t);return(n?.timestamp||0)-(a?.timestamp||0)})[0],n=this.get(t);if(!n)break;this.totalSize-=n.size,this.del(t)}}getTotalSize(){return this.totalSize}getItemCount(){return this.keys().length}}const Qe=new Xe(Ze,Ge);function et(e){if(0===e.length)return Buffer.alloc(0);if(1===e.length)return e[0];const t=e.reduce((e,t)=>Buffer.byteLength(t)+e,0);return Buffer.concat(e,t)}function tt(e){const{devServer:t,ttl:n=Ke,maxSize:a=Ze,maxItems:s=Ge}=e,o=ae();return a===Ze&&s===Ge||(Qe.maxSize=a,Qe.maxItems=s),t.cache=Qe,(e,t,a)=>{const s=e.originalUrl||e.url||"",i=function(e){const t=e.split("?")[0];return Ye.test(t)?t.includes("/auth/info.js")?"":e:""}(s);if(!i)return a();const r=Qe.get(i);if(r)return o.info(`${se.blue("Proxies request:")} ${se.green(e.method)} ${s} -> ${se.blue("Cache")} ${i}`),function(e,t){try{for(const[n,a]of Object.entries(t))null!=a&&e.setHeader(n,a)}catch(e){ae().warn(`Failed to set some response headers: ${e instanceof Error?e.message:String(e)}`)}}(t,r.headers),t.setHeader("X-PP-Cache","hit"),t.write(r.content),void t.end();const l=t.end,c=t.write,d=[];t.write=function(e,n,a){if(!t.hasHeader(ce))return c.call(this,e,n,a);if("string"==typeof e){const t=n||"utf8";d.push(Buffer.from(e,t))}else Buffer.isBuffer(e)?d.push(e):null!=e&&d.push(Buffer.from(String(e),"utf8"));return a?.(),!0},t.end=function(e,a,s){if(!t.hasHeader(ce))return l.call(this,e,a,s);if(null!=e)if("string"==typeof e){const t=a||"utf8";d.push(Buffer.from(e,t))}else Buffer.isBuffer(e)?d.push(e):d.push(Buffer.from(String(e),"utf8"));let r;try{r=et(d);const e=Buffer.byteLength(r);if(e>0&&e<10485760){const a={headers:t.getHeaders(),content:r,timestamp:Date.now(),size:e};Qe.put(i,a,n),o.info(`${se.blue("[Cached]")} ${i} (${(e/1024).toFixed(1)}KB)`)}}catch(e){o.error(`Failed to cache response for ${i}: ${e}`),r=et(d)}const c=a||"utf8";return l.call(this,r,c,s)},a()}}function nt(e,t){return async(n,a,s)=>{if(await e(n.url??"",n,a)){ae().info(`Rewrite response for ${n.url}`);const e=a.end,s=[];a.write=function(e){return s.push(e),!0},a.end=function(o,i,r){"string"==typeof o&&s.push(Buffer.from(o));const l=a.getHeader("content-encoding"),c=function(e,t){switch(t){case"gzip":return N(e);case"br":return j(e);case"deflate":return _(e);default:return e}}(Buffer.from(Buffer.concat(s)),l),d="function"!=typeof i&&i?i:"utf-8",p="function"==typeof i?i:r;return e.call(this,function(e,t){switch(t){case"gzip":return L(e);case"br":return F(e);case"deflate":return H(e);default:return e}}(t(c,n,a),l),d,p),this}}s()}}function at(e,t){const n=(e=(e=e.startsWith("/")?e:`/${e}`).endsWith("/")?e:`${e}/`).endsWith("/")?e.slice(0,-1):e,s=ae();return(o,i,r)=>{const l=new a(o.url??"","http://localhost"),{pathname:c,search:d}=l,p=["/",n];if(t&&(p.push(`/pt/${t}`),p.push(`/pl/${t}`)),p.includes(c)){const t=`${e}${d}`;return s.info(se.yellow(`Redirecting to: ${t}`)),ie(i,t,302)}r()}}class st{state={isAuthenticated:!1,isRedirected:!1,lastChecked:0};listeners=new Set;getState(){return{...this.state}}isAuthenticated(){return this.state.isAuthenticated}isRedirected(){return this.state.isRedirected}setAuthenticated(e){this.state.isAuthenticated=e,this.state.lastChecked=Date.now(),this.notifyListeners()}setRedirected(e){this.state.isRedirected=e,this.notifyListeners()}updateState(e){void 0!==e.isAuthenticated&&(this.state.isAuthenticated=e.isAuthenticated),void 0!==e.isRedirected&&(this.state.isRedirected=e.isRedirected),this.state.lastChecked=Date.now(),this.notifyListeners()}reset(){this.state={isAuthenticated:!1,isRedirected:!1,lastChecked:0},this.notifyListeners()}subscribe(e){return this.listeners.add(e),()=>{this.listeners.delete(e)}}notifyListeners(){this.listeners.forEach(e=>{try{e(this.getState())}catch(e){console.error("Error in auth state listener:",e)}})}}const ot=new st,it=new Map;function rt(e){const t=it.get(e);return t?Date.now()-t.timestamp>18e4?(it.delete(e),null):t.data:null}function lt(e,t){it.set(e,{timestamp:Date.now(),data:t})}const ct="/home?proxyRedirect=";function dt(e,t,n){const{templateLess:a=!1,miHudLess:s=!1,appId:o,base:i,appBase:r,v7Features:l}=n,c=ae();if(a&&s&&void 0===o)throw new Error("Custom App ID is required when both templateLess and miHudLess are true");return async(n,d,p)=>{try{const u=!(a&&s),h=(n.url??"").split("?")[0],m=e.test(h),g=!m&&!h.startsWith("/home")&&function(e,t){if(!t||"/"===t)return!0;const n=t.endsWith("/")?t:`${t}/`;return e===n||e===n.slice(0,-1)||e.startsWith(n)}(h,r)&&function(e){const t=e.headers["sec-fetch-dest"];if("string"==typeof t)return"document"===t;const n=e.headers.accept;return"string"==typeof n&&n.includes("text/html")}(n),f=ot.getState();if(!f.isAuthenticated&&!f.isRedirected&&n.url?.startsWith("/home")&&!n.url?.startsWith(ct)){c.info(se.blue("Trying to authenticate and redirect to base"));try{return u?await ut(t,{templateLess:a,miHudLess:s,appId:o,redirectUrl:l&&i?`${i}`:`${ct}${encodeURIComponent("/")}`},n,d,()=>{},c):await pt(t,{templateLess:a,miHudLess:s,appId:o,redirectUrl:l&&i?`${i}`:`${ct}${encodeURIComponent("/")}`},n,d,()=>{},c),ot.setRedirected(!0),c.info(se.blue("Successfully authenticated. Redirecting to base")),ie(d,i??"/",302)}catch(e){return p()}}if(!m&&!g)return p();if(!u){return await pt(t,{templateLess:a,miHudLess:s,appId:o,redirectUrl:l&&i?`${i}`:`${ct}${encodeURIComponent("/")}`},n,d,p,c)}return await ut(t,{templateLess:a,miHudLess:s,appId:o,redirectUrl:l&&i?`${i}`:`${ct}${encodeURIComponent("/")}`},n,d,p,c)}catch(e){return c.error(se.red(`Unexpected error in load-pp-data middleware: ${e instanceof Error?e.message:String(e)}`)),ot.reset(),p(e)}}}async function pt(e,t,n,a,s,o){const{appId:i,redirectOnAuthFailure:r,redirectUrl:l}=t;if(void 0===i){const e=new Error("Custom App ID is required for page info only mode");return o.error(se.red(e.message)),s(e)}const c=n.headers??{};o.info(se.green("Start loading page info"));try{const t=`pageInfo:${i}`;return rt(t)?(o.info(se.green("Page info loaded from cache")),s()):(await e.getPageInfo(i,c),o.info(se.blue("Clearing proxy cache after successful login")),Qe.clear(),lt(t,{success:!0}),ot.updateState({isAuthenticated:!0}),o.info(se.green("Page info loaded successfully")),s())}catch(e){return ot.reset(),ht(e,r??!0,l||`${ct}${encodeURIComponent("/")}`,a,s,o,"Page info")}}async function ut(e,t,n,a,s,o){const{templateLess:i,appId:r,redirectOnAuthFailure:l,redirectUrl:c}=t,d=n.headers??{};o.info(se.green("Start loading page data"));try{const t=`pageData:${i}:${r}`;if(rt(t))return o.info(se.green("Page data loaded from cache")),s();const n=i||void 0===r?e.getPageTemplate(d):e.getPageVariables(r,d);return await n,o.info(se.blue("Clearing proxy cache after successful login")),Qe.clear(),lt(t,{success:!0}),o.info(se.green("Page data loaded successfully")),s()}catch(e){return ot.reset(),ht(e,l??!0,c||`${ct}${encodeURIComponent("/")}`,a,s,o,"Page data")}}function ht(e,t,n,a,s,o,i){if(function(e){return null!==e&&"object"==typeof e&&"response"in e&&void 0!==e.response}(e)&&(o.info(se.red(`${i} loading failed. Not authorized`)),t)){const e=`${n}`;return o.info(se.yellow(`Redirecting to: ${e}`)),ie(a,e,302)}const r=e instanceof Error?e.message:String(e);return o.info(se.red(`${i} loading failed. Error: ${r}`)),s(e)}function mt(){const e=M();return e.use(M.json()),e.use(M.urlencoded({extended:!0})),e}const gt=mt();class ft{entries=new Map;insertOrder=[];totalSize=0;maxMemory;constructor(e=1073741824){this.maxMemory=e}allocateId(){return P()}add(e){let t=e,n=(e.requestBody?.byteLength??0)+(e.responseBody?.byteLength??0);n>this.maxMemory&&(t={...e,requestBody:null,responseBody:null,requestBodyTruncated:e.requestBodyTruncated||null!==e.requestBody,responseBodyTruncated:e.responseBodyTruncated||null!==e.responseBody},n=0);const a=n+512;for(;this.totalSize+a>this.maxMemory&&this.insertOrder.length>0;){const e=this.insertOrder.shift(),t=this.entries.get(e);t&&(this.totalSize-=(t.requestBody?.byteLength??0)+(t.responseBody?.byteLength??0)+512,this.entries.delete(e))}this.entries.set(t.id,t),this.insertOrder.push(t.id),this.totalSize+=a}get(e){return this.entries.get(e)}list(e){let t=this.insertOrder.slice().map(e=>{const t=this.entries.get(e);return{id:t.id,timestamp:t.timestamp,method:t.method,url:t.url,statusCode:t.statusCode,duration:t.duration,source:t.source,requestSize:t.requestBody?.byteLength??0,responseSize:t.responseBody?.byteLength??0,requestBodyTruncated:t.requestBodyTruncated,responseBodyTruncated:t.responseBodyTruncated}});if(e?.method&&(t=t.filter(t=>t.method===e.method.toUpperCase())),e?.search){const n=e.search.toLowerCase();t=t.filter(e=>e.url.toLowerCase().includes(n))}const n=Number.isFinite(e?.limit)?e.limit:t.length;if(void 0===e?.offset)return t.slice(Math.max(0,t.length-n));const a=Number.isFinite(e.offset)?e.offset:0;return t.slice(a,a+n)}clear(){this.entries.clear(),this.insertOrder=[],this.totalSize=0}get size(){return this.entries.size}get memoryUsage(){return this.totalSize}}const vt=["/@pp-dev/","/@api/","/_next/","/__nextjs_","/@vite","/@metricinsights","/@"],bt=new Set(["/favicon.ico","/installHook.js.map"]);function yt(e,t=10485760){return(n,a,s)=>{const o=n.url||"/",i=o.split("?")[0];if(vt.some(e=>o.startsWith(e))||bt.has(i))return void s();const r=e.allocateId(),l=Date.now(),c=[];let d=0,p=!1;const u=n.emit.bind(n);n.emit=(e,...n)=>{if("data"===e&&!p){const e=n[0],a=Buffer.isBuffer(e)?e:Buffer.from(String(e));d+=a.byteLength,d<=t?c.push(a):(p=!0,c.length=0)}return u(e,...n)};const h=[];let m=0,g=!1;const f=a.write.bind(a),v=a.end.bind(a);function b(e,n){if(!g&&null!=e)try{const a=Buffer.isBuffer(e)?e:"string"==typeof e?Buffer.from(e,n):ArrayBuffer.isView(e)?Buffer.from(e.buffer,e.byteOffset,e.byteLength):e instanceof ArrayBuffer?Buffer.from(e):Buffer.from(String(e));m+=a.byteLength,m<=t?h.push(a):(g=!0,h.length=0)}catch{}}function y(e){return"string"==typeof e[0]?e[0]:void 0}function w(){const e=a.getHeaders();return e["x-pp-proxy"]?"hit"===e["x-pp-cache"]?"proxy-cache":"proxy":"local"}let x=!1;a.write=function(e,...t){return b(e,y(t)),f(e,...t)},a.end=function(t,...s){return"function"!=typeof t&&b(t,y(s)),function(){if(x)return;x=!0;const t=!p&&c.length>0?Buffer.concat(c):null,s=!g&&h.length>0?Buffer.concat(h):null;e.add({id:r,timestamp:l,method:n.method||"GET",url:o,statusCode:a.statusCode??null,duration:Date.now()-l,source:w(),requestHeaders:n.headers,requestBody:t,requestBodyTruncated:p,responseHeaders:a.getHeaders(),responseBody:s,responseBodyTruncated:g})}(),v(t,...s)},s()}}const wt="/@pp-dev/inspector",xt=new WeakMap;function kt(e,t,n=10485760){const a=xt.get(e);if(a)return a.store=t,void(a.captureLimit=n);const s={store:t,captureLimit:n};xt.set(e,s),e.get("/@api/requests",(e,t)=>{const{store:n}=s,{limit:a,offset:o,method:i,search:r}=e.query,l=void 0!==a?parseInt(a,10):void 0,c=n.list({limit:Number.isFinite(l)?l:500,offset:void 0!==o&&Number.isFinite(parseInt(o,10))?parseInt(o,10):void 0,method:i,search:r});t.json({requests:c,total:n.size,memoryUsage:n.memoryUsage,maxMemory:n.maxMemory})}),e.get("/@api/requests/stats",(e,t)=>{const{store:n}=s;t.json({total:n.size,memoryUsage:n.memoryUsage,maxMemory:n.maxMemory,memoryUsageFormatted:Et(n.memoryUsage),maxMemoryFormatted:Et(n.maxMemory)})}),e.get("/@api/requests/:id",(e,t)=>{const n=s.store.get(e.params.id);if(!n)return void t.status(404).json({error:"Not found",id:e.params.id});const a=St(n.requestHeaders["content-type"]),o=St(n.responseHeaders["content-type"]);t.json({id:n.id,timestamp:n.timestamp,method:n.method,url:n.url,statusCode:n.statusCode,duration:n.duration,source:n.source,requestHeaders:n.requestHeaders,requestBody:n.requestBody?n.requestBody.toString("base64"):null,requestBodySize:n.requestBody?.byteLength??0,requestBodyTruncated:n.requestBodyTruncated,requestContentType:a,responseHeaders:n.responseHeaders,responseBody:n.responseBody?n.responseBody.toString("base64"):null,responseBodySize:n.responseBody?.byteLength??0,responseBodyTruncated:n.responseBodyTruncated,responseContentType:o})}),e.delete("/@api/requests",(e,t)=>{s.store.clear(),t.json({ok:!0})}),e.get(wt,(e,t)=>{t.setHeader("Content-Type","text/html; charset=utf-8"),t.setHeader("Cache-Control","no-cache"),t.end(function(e){return`<!DOCTYPE html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width,initial-scale=1">\n<title>Request Inspector — pp-dev</title>\n<script>\n// Set the stored theme override before first paint, so switching pages/reloading doesn't\n// flash the OS-default theme before settling on the user's explicit choice (see setTheme()).\ntry {\n var ppDevStoredTheme = localStorage.getItem('pp-dev-info-theme');\n\n if (ppDevStoredTheme === 'dark' || ppDevStoredTheme === 'light') {\n document.documentElement.setAttribute('data-pp-dev-theme', ppDevStoredTheme);\n }\n} catch (e) {}\n<\/script>\n<style>\n*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}\n:root,:root[data-pp-dev-theme="light"]{\n --bg:#ffffff;--bg2:#f4f5f7;--bg3:#e9ebef;--bg4:#dbdee3;\n --border:#d7dae0;--border2:#c2c6cd;\n --text:#1a1a1e;--text2:#54566b;--text3:#8a8c9c;\n --accent:#4f46e5;--accent2:#7c3aed;\n --green:#16a34a;--red:#dc2626;--yellow:#a16207;--blue:#2563eb;--orange:#c2410c;--purple:#9333ea;\n --json-text:#24292f;--json-key:#0550ae;--json-str:#0a3069;--json-num:#953800;--json-bool:#cf222e;\n --btn-primary-fg:#ffffff;\n --font-mono:'Cascadia Code','Fira Code','JetBrains Mono',Consolas,monospace;\n --font-ui:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;\n}\n:root[data-pp-dev-theme="dark"]{\n --bg:#1a1a1e;--bg2:#222228;--bg3:#2a2a32;--bg4:#32323c;\n --border:#3a3a46;--border2:#4a4a58;\n --text:#e0e0f0;--text2:#a0a0b8;--text3:#606078;\n --accent:#6e8efb;--accent2:#a78bfa;\n --green:#4ade80;--red:#f87171;--yellow:#fbbf24;--blue:#60a5fa;--orange:#fb923c;--purple:#c084fc;\n --json-text:#c9d1d9;--json-key:#79b8ff;--json-str:#9ecbff;--json-num:#f8c555;--json-bool:#f97583;\n --btn-primary-fg:#0d0d10;\n}\n@media (prefers-color-scheme: dark){\n :root{\n --bg:#1a1a1e;--bg2:#222228;--bg3:#2a2a32;--bg4:#32323c;\n --border:#3a3a46;--border2:#4a4a58;\n --text:#e0e0f0;--text2:#a0a0b8;--text3:#606078;\n --accent:#6e8efb;--accent2:#a78bfa;\n --green:#4ade80;--red:#f87171;--yellow:#fbbf24;--blue:#60a5fa;--orange:#fb923c;--purple:#c084fc;\n --json-text:#c9d1d9;--json-key:#79b8ff;--json-str:#9ecbff;--json-num:#f8c555;--json-bool:#f97583;\n --btn-primary-fg:#0d0d10;\n }\n}\nhtml,body{height:100%;overflow:hidden;background:var(--bg);color:var(--text);font-family:var(--font-ui);font-size:13px;line-height:1.5}\n\n/* ── Layout ── */\n.app{display:flex;flex-direction:column;height:100vh}\n.toolbar{display:flex;align-items:center;gap:8px;padding:8px 12px;background:var(--bg2);border-bottom:1px solid var(--border);flex-shrink:0;flex-wrap:wrap}\n.toolbar-title{font-weight:600;font-size:14px;color:var(--text);margin-right:4px}\n.toolbar-badge{font-size:11px;padding:2px 7px;border-radius:10px;background:var(--bg4);color:var(--text2)}\n.toolbar-spacer{flex:1}\n.theme-switch{display:flex;gap:2px;background:var(--bg3);border:1px solid var(--border);border-radius:4px;padding:2px}\n.theme-switch button{padding:3px 8px;border:none;border-radius:3px;background:transparent;color:var(--text2);font-size:11px;cursor:pointer;font-family:var(--font-ui)}\n.theme-switch button.active{background:var(--accent);color:var(--btn-primary-fg)}\n.mem-bar-wrap{display:flex;align-items:center;gap:6px;font-size:11px;color:var(--text2)}\n.mem-bar{width:80px;height:6px;background:var(--bg4);border-radius:3px;overflow:hidden}\n.mem-bar-fill{height:100%;background:var(--accent);transition:width .4s}\n\n.split{display:flex;flex:1;overflow:hidden}\n.pane-list{width:380px;min-width:200px;max-width:60%;border-right:1px solid var(--border);display:flex;flex-direction:column;flex-shrink:0}\n.pane-detail{flex:1;display:flex;flex-direction:column;overflow:hidden}\n\n/* ── Filter bar ── */\n.filter-bar{display:flex;flex-wrap:wrap;gap:6px;padding:8px 10px;background:var(--bg2);border-bottom:1px solid var(--border);flex-shrink:0}\n.filter-input{flex:1 1 120px;min-width:0;background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:4px 8px;border-radius:4px;font-size:12px;outline:none}\n.filter-input:focus{border-color:var(--accent)}\n.filter-selects{display:flex;gap:6px;flex-shrink:0}\n.filter-select{background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:4px 6px;border-radius:4px;font-size:12px;outline:none;cursor:pointer;max-width:90px}\n.filter-select:focus{border-color:var(--accent)}\n\n/* ── Request list ── */\n.req-list{flex:1;overflow-y:auto;overflow-x:hidden}\n.req-list::-webkit-scrollbar{width:6px}\n.req-list::-webkit-scrollbar-track{background:var(--bg)}\n.req-list::-webkit-scrollbar-thumb{background:var(--bg4);border-radius:3px}\n.req-item{display:flex;align-items:center;gap:8px;padding:7px 10px 7px 9px;border-bottom:1px solid var(--border);border-left:3px solid transparent;cursor:pointer;user-select:none;transition:background .1s,border-left-color .1s}\n.req-item:hover{background:var(--bg3)}\n.req-item.selected{background:var(--bg4);border-left-color:var(--accent) !important}\n.req-item.src-proxy{border-left-color:var(--accent2)}\n.req-item.src-proxy-cache{border-left-color:var(--yellow)}\n.req-item.src-local{border-left-color:transparent}\n\n.method-badge{font-size:10px;font-weight:700;padding:2px 5px;border-radius:3px;min-width:38px;text-align:center;letter-spacing:.4px;flex-shrink:0}\n.m-GET{background:color-mix(in srgb, var(--green) 20%, var(--bg));color:var(--green)}\n.m-POST{background:color-mix(in srgb, var(--blue) 20%, var(--bg));color:var(--blue)}\n.m-PUT{background:color-mix(in srgb, var(--orange) 20%, var(--bg));color:var(--orange)}\n.m-PATCH{background:color-mix(in srgb, var(--yellow) 20%, var(--bg));color:var(--yellow)}\n.m-DELETE{background:color-mix(in srgb, var(--red) 20%, var(--bg));color:var(--red)}\n.m-HEAD,.m-OPTIONS{background:color-mix(in srgb, var(--purple) 20%, var(--bg));color:var(--purple)}\n.m-other{background:var(--bg4);color:var(--text2)}\n\n.req-url{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;color:var(--text)}\n.req-meta{display:flex;flex-direction:column;align-items:flex-end;gap:1px;flex-shrink:0;font-size:10px;color:var(--text3)}\n.req-status-row{display:flex;align-items:center;gap:4px}\n.source-letter{font-size:9px;font-weight:700;width:15px;height:15px;display:flex;align-items:center;justify-content:center;border-radius:2px;flex-shrink:0}\n.src-letter-proxy{background:color-mix(in srgb, var(--accent2) 20%, var(--bg));color:var(--accent2)}\n.src-letter-proxy-cache{background:color-mix(in srgb, var(--yellow) 20%, var(--bg));color:var(--yellow)}\n.src-letter-local{background:var(--bg4);color:var(--text3)}\n.status-badge{font-size:10px;font-weight:600;padding:1px 4px;border-radius:2px}\n.s-2xx{color:var(--green)}\n.s-3xx{color:var(--blue)}\n.s-4xx{color:var(--yellow)}\n.s-5xx{color:var(--red)}\n.s-pending{color:var(--text3)}\n\n.source-chip{display:inline-flex;align-items:center;gap:4px;font-size:11px;font-weight:500;padding:2px 7px;border-radius:10px}\n.src-local{background:color-mix(in srgb, var(--green) 20%, var(--bg));color:var(--green)}\n.src-proxy{background:color-mix(in srgb, var(--accent2) 20%, var(--bg));color:var(--accent2)}\n.src-proxy-cache{background:color-mix(in srgb, var(--yellow) 20%, var(--bg));color:var(--yellow)}\n\n.empty-state{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;color:var(--text3);gap:8px;padding:40px}\n.empty-icon{font-size:40px;opacity:.5}\n\n/* ── Detail pane ── */\n.detail-empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;color:var(--text3);gap:8px}\n.detail-scroll{flex:1;overflow-y:auto;overflow-x:hidden;padding:0}\n.detail-scroll::-webkit-scrollbar{width:6px}\n.detail-scroll::-webkit-scrollbar-track{background:var(--bg)}\n.detail-scroll::-webkit-scrollbar-thumb{background:var(--bg4);border-radius:3px}\n\n.detail-section{border-bottom:1px solid var(--border)}\n.detail-section-hdr{display:flex;align-items:center;gap:8px;padding:8px 12px;background:var(--bg2);cursor:pointer;user-select:none;position:sticky;top:0;z-index:1}\n.detail-section-hdr:hover{background:var(--bg3)}\n.detail-section-hdr .toggle{font-size:10px;color:var(--text3)}\n.section-title{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.6px;color:var(--text2)}\n.section-badge{font-size:10px;padding:1px 5px;border-radius:8px;background:var(--bg4);color:var(--text3)}\n.section-actions{display:flex;gap:4px;margin-left:auto;align-items:center}\n.section-btn{font-size:10px;padding:1px 7px;border-radius:3px;border:1px solid var(--border);background:transparent;color:var(--text3);cursor:pointer;line-height:1.6;transition:all .1s;font-family:var(--font-ui)}\n.section-btn:hover{background:var(--bg4);color:var(--text);border-color:var(--border2)}\n.detail-section-body{padding:8px 12px;font-family:var(--font-mono);font-size:12px}\n.detail-section-body.collapsed{display:none}\n\n.kv-table{width:100%;border-collapse:collapse}\n.kv-table tr:hover td{background:var(--bg3)}\n.kv-table td{padding:2px 0;vertical-align:top}\n.kv-table td:first-child{color:var(--text2);white-space:nowrap;padding-right:12px;min-width:140px;max-width:220px;overflow:hidden;text-overflow:ellipsis}\n.kv-table td:last-child{color:var(--text);word-break:break-all}\n\n.body-content{white-space:pre-wrap;word-break:break-all;color:var(--text);line-height:1.6;max-height:400px;overflow:auto}\n.body-content.json{color:var(--json-text)}\n.body-json-key{color:var(--json-key)}\n.body-json-str{color:var(--json-str)}\n.body-json-num{color:var(--json-num)}\n.body-json-bool{color:var(--json-bool)}\n.body-json-null{color:var(--json-bool);font-style:italic}\n.body-binary{color:var(--text3);font-style:italic;padding:8px 0}\n.body-image{max-width:100%;max-height:300px;object-fit:contain;margin-top:4px;border:1px solid var(--border);border-radius:4px}\n.truncated-note{font-size:11px;color:var(--yellow);margin-top:4px;font-family:var(--font-ui)}\n\n.detail-info-bar{display:flex;gap:16px;padding:10px 12px;background:var(--bg2);border-bottom:1px solid var(--border);flex-shrink:0;flex-wrap:wrap}\n.info-item{display:flex;flex-direction:column;gap:2px}\n.info-label{font-size:10px;color:var(--text3);text-transform:uppercase;letter-spacing:.5px}\n.info-value{font-size:13px;font-weight:600;color:var(--text)}\n.info-url{display:flex;align-items:center;gap:8px;font-size:11px;font-family:var(--font-mono);color:var(--text2);padding:6px 12px;border-bottom:1px solid var(--border);background:var(--bg)}\n.info-url-text{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n\n/* ── Buttons ── */\n.btn{padding:4px 10px;border-radius:4px;border:1px solid var(--border);background:var(--bg3);color:var(--text);font-size:12px;cursor:pointer;transition:background .1s}\n.btn:hover{background:var(--bg4);border-color:var(--border2)}\n.btn-danger:hover{background:color-mix(in srgb, var(--red) 15%, var(--bg));border-color:color-mix(in srgb, var(--red) 45%, var(--bg));color:var(--red)}\n.btn-sm{padding:2px 7px;font-size:11px}\n\n/* ── Live indicator ── */\n.live-dot{width:7px;height:7px;border-radius:50%;background:var(--green);animation:pulse 2s infinite}\n@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}\n\n/* ── Scrollbar ── */\n::-webkit-scrollbar{width:6px;height:6px}\n::-webkit-scrollbar-track{background:var(--bg)}\n::-webkit-scrollbar-thumb{background:var(--bg4);border-radius:3px}\n</style>\n</head>\n<body>\n<div class="app">\n <div class="toolbar">\n <span class="toolbar-title">🔍 Request Inspector</span>\n <span class="live-dot" title="Live"></span>\n <span class="toolbar-badge" id="req-count">0 requests</span>\n <div class="mem-bar-wrap">\n <div class="mem-bar"><div class="mem-bar-fill" id="mem-fill" style="width:0%"></div></div>\n <span id="mem-text">0 B / 1 GB</span>\n </div>\n <div class="toolbar-spacer"></div>\n <div class="theme-switch" id="theme-switch">\n <button data-theme-choice="auto" aria-pressed="false" onclick="setTheme('auto')">Auto</button>\n <button data-theme-choice="dark" aria-pressed="false" onclick="setTheme('dark')">Dark</button>\n <button data-theme-choice="light" aria-pressed="false" onclick="setTheme('light')">Light</button>\n </div>\n <button class="btn btn-sm btn-danger" onclick="clearAll()">Clear</button>\n </div>\n <div class="split">\n <div class="pane-list">\n <div class="filter-bar">\n <input class="filter-input" id="search-input" placeholder="Filter by URL…" oninput="applyFilter()" />\n <div class="filter-selects">\n <select class="filter-select" id="method-select" onchange="applyFilter()">\n <option value="">All</option>\n <option>GET</option><option>POST</option><option>PUT</option>\n <option>PATCH</option><option>DELETE</option><option>HEAD</option>\n </select>\n <select class="filter-select" id="status-select" onchange="applyFilter()">\n <option value="">All</option>\n <option value="2">2xx</option><option value="3">3xx</option>\n <option value="4">4xx</option><option value="5">5xx</option>\n </select>\n <select class="filter-select" id="source-select" onchange="applyFilter()">\n <option value="">All</option>\n <option value="local">Local</option>\n <option value="proxy">Proxy</option>\n <option value="proxy-cache">Cached</option>\n </select>\n </div>\n </div>\n <div class="req-list" id="req-list">\n <div class="empty-state">\n <div class="empty-icon">📡</div>\n <div>No requests captured yet</div>\n <div style="font-size:11px">Requests to the dev server will appear here</div>\n </div>\n </div>\n </div>\n <div class="pane-detail">\n <div class="detail-empty" id="detail-empty">\n <div style="font-size:32px;opacity:.3">←</div>\n <div>Select a request to inspect</div>\n </div>\n <div id="detail-panel" style="display:none;flex:1;flex-direction:column;overflow:hidden">\n <div class="detail-info-bar" id="detail-info"></div>\n <div class="info-url" id="detail-url"></div>\n <div class="detail-scroll" id="detail-scroll"></div>\n </div>\n </div>\n </div>\n</div>\n<script>\n(function(){\n'use strict';\n\n// ── Theme (Auto/Dark/Light) ──────────────────────────────────────────────────\n// The <head> inline script already set data-pp-dev-theme before first paint; this just keeps\n// it in sync with future picks and syncs the toolbar buttons' state. Shares its localStorage\n// key and attribute name with the dev panel and the Variables Editor, so picking a theme in\n// any of them carries over to the others.\nconst THEME_STORAGE_KEY = 'pp-dev-info-theme';\n\nfunction getStoredTheme() {\n try { return localStorage.getItem(THEME_STORAGE_KEY) || 'auto'; } catch (e) { return 'auto'; }\n}\n\nfunction applyTheme(theme) {\n if (theme === 'dark' || theme === 'light') {\n document.documentElement.setAttribute('data-pp-dev-theme', theme);\n } else {\n document.documentElement.removeAttribute('data-pp-dev-theme');\n }\n\n const switchEl = document.getElementById('theme-switch');\n\n if (switchEl) {\n Array.prototype.forEach.call(switchEl.children, (btn) => {\n const isActive = btn.dataset.themeChoice === theme;\n\n btn.classList.toggle('active', isActive);\n btn.setAttribute('aria-pressed', String(isActive));\n });\n }\n}\n\nfunction setTheme(theme) {\n try { localStorage.setItem(THEME_STORAGE_KEY, theme); } catch (e) {}\n\n applyTheme(theme);\n}\n\napplyTheme(getStoredTheme());\nwindow.setTheme = setTheme;\n\nconst CAPTURE_LIMIT = ${e};\n\nlet allRequests = [];\nlet filteredRequests = [];\nlet selectedId = null;\nlet currentEntry = null;\n\nconst listEl = document.getElementById('req-list');\nconst detailEmpty = document.getElementById('detail-empty');\nconst detailPanel = document.getElementById('detail-panel');\nconst detailInfo = document.getElementById('detail-info');\nconst detailUrl = document.getElementById('detail-url');\nconst detailScroll = document.getElementById('detail-scroll');\n\n// ── Fetch request list ────────────────────────────────────────────────────────\nasync function fetchList() {\n try {\n const r = await fetch('/@api/requests');\n const data = await r.json();\n\n allRequests = data.requests || [];\n updateStats(data);\n applyFilter();\n\n if (selectedId && !allRequests.find(r => r.id === selectedId)) {\n selectedId = null;\n showDetailEmpty();\n }\n } catch(e) {}\n}\n\nfunction updateStats(data) {\n const pct = data.maxMemory > 0 ? Math.min(100, (data.memoryUsage / data.maxMemory) * 100) : 0;\n\n document.getElementById('req-count').textContent = data.total + ' request' + (data.total !== 1 ? 's' : '');\n document.getElementById('mem-fill').style.width = pct + '%';\n document.getElementById('mem-text').textContent = formatBytes(data.memoryUsage) + ' / ' + formatBytes(data.maxMemory);\n}\n\n// ── Filter ────────────────────────────────────────────────────────────────────\nfunction applyFilter() {\n const search = document.getElementById('search-input').value.toLowerCase();\n const method = document.getElementById('method-select').value;\n const status = document.getElementById('status-select').value;\n const source = document.getElementById('source-select').value;\n\n filteredRequests = allRequests.filter(r => {\n if (method && r.method !== method) {\n return false;\n }\n\n if (status && !String(r.statusCode || '').startsWith(status)) {\n return false;\n }\n\n if (source && r.source !== source) {\n return false;\n }\n\n if (search && !r.url.toLowerCase().includes(search)) {\n return false;\n }\n\n return true;\n });\n\n renderList();\n}\n\n// ── Render list ───────────────────────────────────────────────────────────────\nfunction isAtBottom() {\n return listEl.scrollTop + listEl.clientHeight >= listEl.scrollHeight - 16;\n}\n\nfunction renderList() {\n const atBottom = isAtBottom();\n\n if (!filteredRequests.length) {\n listEl.innerHTML = '<div class="empty-state"><div class="empty-icon">🔍</div><div>No matching requests</div></div>';\n\n return;\n }\n\n const html = filteredRequests.map(r => {\n const sel = r.id === selectedId ? ' selected' : '';\n const mClass = methodClass(r.method);\n const sClass = statusClass(r.statusCode);\n const dur = r.duration != null ? formatDur(r.duration) : '…';\n const size = formatBytes((r.responseSize || 0));\n const srcCls = 'src-' + (r.source || 'local');\n const sLetter = sourceLetter(r.source);\n const sLetterCls = 'src-letter-' + (r.source || 'local').replace(/-/g, '-');\n\n return \`<div class="req-item \${srcCls}\${sel}" data-id="\${esc(r.id)}" onclick="selectRequest('\${esc(r.id)}')">\n <span class="method-badge \${mClass}">\${esc(r.method)}</span>\n <span class="req-url" title="\${esc(r.url)}">\${esc(r.url)}</span>\n <span class="req-meta">\n <span class="req-status-row">\n <span class="source-letter \${sLetterCls}">\${sLetter}</span>\n <span class="status-badge \${sClass}">\${r.statusCode || '…'}</span>\n </span>\n <span>\${dur}</span>\n <span>\${size}</span>\n </span>\n </div>\`;\n }).join('');\n\n listEl.innerHTML = html;\n\n if (atBottom) {\n listEl.scrollTop = listEl.scrollHeight;\n }\n}\n\n// ── Select request ────────────────────────────────────────────────────────────\nasync function selectRequest(id) {\n selectedId = id;\n\n renderList();\n\n detailEmpty.style.display = 'none';\n detailPanel.style.display = 'flex';\n detailPanel.style.flexDirection = 'column';\n detailPanel.style.overflow = 'hidden';\n detailScroll.innerHTML = '<div style="padding:20px;color:var(--text3)">Loading…</div>';\n\n try {\n const r = await fetch('/@api/requests/' + encodeURIComponent(id));\n\n if (!r.ok) {\n showDetailEmpty();\n\n return;\n }\n\n const entry = await r.json();\n\n renderDetail(entry);\n } catch(e) {\n showDetailEmpty();\n }\n}\nwindow.selectRequest = selectRequest;\n\nfunction showDetailEmpty() {\n detailPanel.style.display = 'none';\n detailEmpty.style.display = 'flex';\n}\n\nfunction renderDetail(e) {\n currentEntry = e;\n\n const sClass = statusClass(e.statusCode);\n const src = sourceInfo(e.source);\n\n detailInfo.innerHTML = \`\n <div class="info-item"><div class="info-label">Method</div><div class="info-value"><span class="method-badge \${methodClass(e.method)}">\${esc(e.method)}</span></div></div>\n <div class="info-item"><div class="info-label">Status</div><div class="info-value"><span class="status-badge \${sClass}">\${e.statusCode || '—'}</span></div></div>\n <div class="info-item"><div class="info-label">Source</div><div class="info-value"><span class="source-chip \${src.cls}">\${src.title}</span></div></div>\n <div class="info-item"><div class="info-label">Duration</div><div class="info-value">\${e.duration != null ? formatDur(e.duration) : '—'}</div></div>\n <div class="info-item"><div class="info-label">Request</div><div class="info-value">\${formatBytes(e.requestBodySize||0)}</div></div>\n <div class="info-item"><div class="info-label">Response</div><div class="info-value">\${formatBytes(e.responseBodySize||0)}</div></div>\n <div class="info-item"><div class="info-label">Time</div><div class="info-value" style="font-size:11px">\${new Date(e.timestamp).toLocaleTimeString()}</div></div>\n \`;\n\n detailUrl.innerHTML = \`<span class="info-url-text" title="\${esc(e.url)}">\${esc(e.url)}</span><button class="section-btn" onclick="copyUrl(this)">Copy link</button>\`;\n\n const sections = [\n makeHeadersSection('Request Headers', e.requestHeaders, 'request'),\n makeBodySection('Request Body', e.requestBody, e.requestContentType, e.requestBodyTruncated, e.requestBodySize, 'request'),\n makeHeadersSection('Response Headers', e.responseHeaders, 'response'),\n makeBodySection('Response Body', e.responseBody, e.responseContentType, e.responseBodyTruncated, e.responseBodySize, 'response'),\n ];\n\n detailScroll.innerHTML = sections.join('');\n}\n\nfunction makeHeadersSection(title, headers, type) {\n const entries = Object.entries(headers || {}).filter(([,v]) => v != null);\n const rows = entries.map(([k,v]) => \`<tr><td>\${esc(k)}</td><td>\${esc(Array.isArray(v) ? v.join(', ') : String(v))}</td></tr>\`).join('');\n const actionsHtml = entries.length ? \`<div class="section-actions"><button class="section-btn" onclick="event.stopPropagation();copyHeaders('\${type}',this)">Copy</button></div>\` : '';\n\n return \`<div class="detail-section">\n <div class="detail-section-hdr" onclick="toggleSection(this)">\n <span class="toggle">▾</span>\n <span class="section-title">\${title}</span>\n <span class="section-badge">\${entries.length}</span>\n \${actionsHtml}\n </div>\n <div class="detail-section-body">\n <table class="kv-table">\${rows}</table>\n </div>\n </div>\`;\n}\n\nfunction makeBodySection(title, b64, contentType, truncated, size, type) {\n let bodyHtml = '';\n const ct = (contentType || '').toLowerCase().split(';')[0].trim();\n const isText = ct.includes('json') || ct.startsWith('text/') || ct.includes('/javascript') || ct === 'application/xml' || ct === 'application/x-www-form-urlencoded';\n\n if (!b64 && !truncated) {\n bodyHtml = '<div class="body-binary">No body</div>';\n } else if (truncated) {\n bodyHtml = \`<div class="body-binary">Body too large to capture (\${formatBytes(size)})</div>\`;\n } else if (b64) {\n if (ct.includes('json') || ct.includes('/javascript') || ct === 'text/plain') {\n const text = decodeTextBody(b64);\n\n if (ct.includes('json')) {\n try {\n bodyHtml = \`<pre class="body-content json">\${syntaxHighlightJson(text)}</pre>\`;\n } catch {\n bodyHtml = \`<pre class="body-content">\${esc(text)}</pre>\`;\n }\n } else {\n bodyHtml = \`<pre class="body-content">\${esc(text)}</pre>\`;\n }\n } else if (ct.startsWith('text/')) {\n bodyHtml = \`<pre class="body-content">\${esc(decodeTextBody(b64))}</pre>\`;\n } else if (ct.startsWith('image/') && size < 1024*1024) {\n bodyHtml = \`<img class="body-image" src="data:\${esc(ct)};base64,\${b64}" alt="image"/>\`;\n } else if (ct === 'application/x-www-form-urlencoded') {\n const params = new URLSearchParams(decodeTextBody(b64));\n const rows = [...params.entries()].map(([k,v]) => \`<tr><td>\${esc(k)}</td><td>\${esc(v)}</td></tr>\`).join('');\n\n bodyHtml = \`<table class="kv-table">\${rows}</table>\`;\n } else {\n bodyHtml = \`<div class="body-binary">Binary data — \${formatBytes(size)} (\${esc(ct || 'unknown type')})</div>\`;\n }\n }\n\n const copyBtn = b64 && isText ? \`<button class="section-btn" onclick="event.stopPropagation();copyBody('\${type}',this)">Copy</button>\` : '';\n const saveBtn = b64 ? \`<button class="section-btn" onclick="event.stopPropagation();saveBody('\${type}')">Save</button>\` : '';\n const actionsHtml = (copyBtn || saveBtn) ? \`<div class="section-actions">\${copyBtn}\${saveBtn}</div>\` : '';\n const note = truncated ? \`<div class="truncated-note">⚠ Body was larger than the capture limit (\${formatBytes(CAPTURE_LIMIT)})</div>\` : '';\n\n return \`<div class="detail-section">\n <div class="detail-section-hdr" onclick="toggleSection(this)">\n <span class="toggle">▾</span>\n <span class="section-title">\${title}</span>\n \${size ? \`<span class="section-badge">\${formatBytes(size)}</span>\` : ''}\n \${actionsHtml}\n </div>\n <div class="detail-section-body">\${bodyHtml}\${note}</div>\n </div>\`;\n}\n\nwindow.toggleSection = function(hdr) {\n const body = hdr.nextElementSibling;\n const toggle = hdr.querySelector('.toggle');\n\n if (body.classList.toggle('collapsed')) {\n toggle.textContent = '▸';\n } else {\n toggle.textContent = '▾';\n }\n};\n\nasync function clearAll() {\n await fetch('/@api/requests', { method: 'DELETE' });\n\n allRequests = [];\n filteredRequests = [];\n selectedId = null;\n\n showDetailEmpty();\n renderList();\n updateStats({ total: 0, memoryUsage: 0, maxMemory: 1024*1024*1024 });\n}\nwindow.clearAll = clearAll;\n\n// ── JSON syntax highlighting ──────────────────────────────────────────────────\nfunction syntaxHighlightJson(str) {\n try {\n const parsed = JSON.parse(str);\n\n str = JSON.stringify(parsed, null, 2);\n } catch {\n // Invalid JSON can contain arbitrary HTML. It cannot be token-highlighted safely because\n // unmatched text is preserved by the replacement below, so render it as escaped plain text.\n return esc(str);\n }\n\n return str.replace(/("(\\\\u[a-zA-Z0-9]{4}|\\\\[^u]|[^\\\\"])*"\\s*:?|\\b(true|false|null)\\b|-?\\d+(?:\\.\\d*)?(?:[eE][+\\-]?\\d+)?)/g, function(m) {\n if (/^"/.test(m)) {\n if (/:$/.test(m)) {\n return '<span class="body-json-key">' + esc(m) + '</span>';\n }\n\n return '<span class="body-json-str">' + esc(m) + '</span>';\n }\n\n if (/true|false/.test(m)) {\n return '<span class="body-json-bool">' + m + '</span>';\n }\n\n if (/null/.test(m)) {\n return '<span class="body-json-null">' + m + '</span>';\n }\n\n return '<span class="body-json-num">' + m + '</span>';\n });\n}\n\n// ── Helpers ───────────────────────────────────────────────────────────────────\nfunction esc(s) {\n return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');\n}\n\nfunction decodeTextBody(b64) {\n const bytes = Uint8Array.from(atob(b64), function(c) { return c.charCodeAt(0); });\n\n return new TextDecoder('utf-8').decode(bytes);\n}\n\nfunction sourceInfo(source) {\n if (source === 'proxy') {\n return { cls: 'src-proxy', title: 'Backend (live)' };\n }\n\n if (source === 'proxy-cache') {\n return { cls: 'src-proxy-cache', title: 'Backend (cached)' };\n }\n\n return { cls: 'src-local', title: 'Local (dev server)' };\n}\n\nfunction sourceLetter(source) {\n if (source === 'proxy') {\n return 'P';\n }\n\n if (source === 'proxy-cache') {\n return 'C';\n }\n\n return 'L';\n}\n\n// ── Copy / Save ───────────────────────────────────────────────────────────────\nfunction clipboard(text, btn) {\n navigator.clipboard.writeText(text).then(() => {\n if (!btn) {\n return;\n }\n\n const orig = btn.textContent;\n\n btn.textContent = '✓';\n btn.style.color = 'var(--green)';\n setTimeout(() => {\n btn.textContent = orig;\n btn.style.color = '';\n }, 1500);\n }).catch(() => {});\n}\n\nfunction copyUrl(btn) {\n clipboard(currentEntry ? currentEntry.url : '', btn);\n}\nwindow.copyUrl = copyUrl;\n\nfunction copyHeaders(type, btn) {\n const h = type === 'request' ? (currentEntry && currentEntry.requestHeaders) : (currentEntry && currentEntry.responseHeaders);\n\n if (!h) {\n return;\n }\n\n const text = Object.entries(h).filter(([,v]) => v != null).map(([k,v]) => k + ': ' + (Array.isArray(v) ? v.join(', ') : v)).join('\\n');\n\n clipboard(text, btn);\n}\nwindow.copyHeaders = copyHeaders;\n\nfunction copyBody(type, btn) {\n const b64 = type === 'request' ? (currentEntry && currentEntry.requestBody) : (currentEntry && currentEntry.responseBody);\n\n if (!b64) {\n return;\n }\n\n try {\n clipboard(decodeTextBody(b64), btn);\n } catch(e) {}\n}\nwindow.copyBody = copyBody;\n\nfunction saveBody(type) {\n const b64 = type === 'request' ? (currentEntry && currentEntry.requestBody) : (currentEntry && currentEntry.responseBody);\n const ct = type === 'request' ? (currentEntry && currentEntry.requestContentType) : (currentEntry && currentEntry.responseContentType);\n\n if (!b64) {\n return;\n }\n\n downloadB64(b64, type + '-body.' + fileExt(ct));\n}\nwindow.saveBody = saveBody;\n\nfunction downloadB64(b64, filename) {\n try {\n const bytes = Uint8Array.from(atob(b64), function(c) { return c.charCodeAt(0); });\n const blob = new Blob([bytes]);\n const url = URL.createObjectURL(blob);\n const a = document.createElement('a');\n\n a.href = url;\n a.download = filename;\n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n setTimeout(function() { URL.revokeObjectURL(url); }, 1000);\n } catch(e) {}\n}\n\nfunction fileExt(ct) {\n ct = ((ct || '').split(';')[0]).trim().toLowerCase();\n\n var map = {\n 'application/json': 'json', 'text/html': 'html', 'text/css': 'css',\n 'text/javascript': 'js', 'application/javascript': 'js', 'text/plain': 'txt',\n 'text/xml': 'xml', 'application/xml': 'xml', 'image/png': 'png',\n 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/svg+xml': 'svg',\n 'image/webp': 'webp', 'application/pdf': 'pdf', 'application/zip': 'zip',\n 'application/x-www-form-urlencoded': 'txt', 'application/octet-stream': 'bin',\n };\n\n return map[ct] || 'bin';\n}\n\nfunction formatBytes(b) {\n b = b || 0;\n\n if (b < 1024) {\n return b + ' B';\n }\n\n if (b < 1024*1024) {\n return (b/1024).toFixed(1) + ' KB';\n }\n\n if (b < 1024*1024*1024) {\n return (b/(1024*1024)).toFixed(1) + ' MB';\n }\n\n return (b/(1024*1024*1024)).toFixed(2) + ' GB';\n}\n\nfunction formatDur(ms) {\n if (ms < 1000) {\n return ms + 'ms';\n }\n\n return (ms/1000).toFixed(2) + 's';\n}\n\nfunction methodClass(m) {\n const map = { GET:'m-GET', POST:'m-POST', PUT:'m-PUT', PATCH:'m-PATCH', DELETE:'m-DELETE', HEAD:'m-HEAD', OPTIONS:'m-OPTIONS' };\n\n return map[m] || 'm-other';\n}\n\nfunction statusClass(s) {\n if (!s) {\n return 's-pending';\n }\n\n const c = Math.floor(s / 100);\n\n return ['','','s-2xx','s-3xx','s-4xx','s-5xx'][c] || '';\n}\n\n// ── Polling ───────────────────────────────────────────────────────────────────\nfetchList();\nsetInterval(fetchList, 2000);\n\n})();\n<\/script>\n</body>\n</html>`}(s.captureLimit))})}function St(e){return Array.isArray(e)?e[0]??"":String(e??"")}function Et(e){return e<1024?`${e} B`:e<1048576?`${(e/1024).toFixed(1)} KB`:e<1073741824?`${(e/1048576).toFixed(1)} MB`:`${(e/1073741824).toFixed(2)} GB`}const Tt=["true","false","Y","N","1","0"],It=/^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;function At(e,t,n,a){if("string"!=typeof a)return[{name:e,severity:"warning",message:`${t}: field "${n.name}" should be a string, got ${typeof a}.`}];if(!a)return[];switch(n.type){case"color":return It.test(a)?[]:[{name:e,severity:"warning",message:`${t}: field "${n.name}" value "${a}" is not a recognized color (expected e.g. "#075b7e").`}];case"select":return n.options?.length&&!n.options.includes(a)?[{name:e,severity:"warning",message:`${t}: field "${n.name}" value "${a}" is not one of the declared options (${n.options.join(", ")}).`}]:[];case"multi-select":{if(!n.options?.length)return[];const s=a.split(",").map(e=>e.trim()).filter(e=>!n.options.includes(e));return s.length?[{name:e,severity:"warning",message:`${t}: field "${n.name}" value "${s.join(", ")}" is not among the declared options (${n.options.join(", ")}).`}]:[]}default:return[]}}function Rt(e,t){const n=function(e){if(!Array.isArray(e)||0===e.length)return null;const t=e.map(e=>"string"==typeof e?{name:e,type:"textarea"}:e&&"object"==typeof e&&"string"==typeof e.name?e:null).filter(e=>null!==e);return t.length?t:null}(e.additional_options);if(!n)return[];const a=new Set(n.map(e=>e.name)),s=[];return t.forEach((t,o)=>{const i=`"${e.name}" item #${o+1}`;if("object"!=typeof t||null===t||Array.isArray(t))return void s.push({name:e.name,severity:"warning",message:`${i} should be an object with field(s): ${n.map(e=>e.name).join(", ")}.`});const r=t;for(const t of n)t.name in r?s.push(...At(e.name,i,t,r[t.name])):s.push({name:e.name,severity:"warning",message:`${i} is missing field "${t.name}".`});const l=Object.keys(r).filter(e=>!a.has(e));l.length&&s.push({name:e.name,severity:"warning",message:`${i} has unexpected field(s) not declared in additional_options: ${l.join(", ")}.`})}),s}function $t(e,t){const n=[];switch(e.tag_type){case"boolean":Tt.includes(t)||n.push({name:e.name,severity:"warning",message:`Value "${t}" is not a recognized boolean encoding (expected one of ${Tt.join(", ")}).`});break;case"list":try{const a=JSON.parse(t);Array.isArray(a)?n.push(...Rt(e,a)):n.push({name:e.name,severity:"warning",message:`Value for list variable "${e.name}" is valid JSON but not an array.`})}catch{n.push({name:e.name,severity:"warning",message:`Value for list variable "${e.name}" is not valid JSON.`})}break;case"select":case"multiselect":if(a=e.additional_options,Array.isArray(a)){const a=e.additional_options,s=("multiselect"===e.tag_type?t.split(",").map(e=>e.trim()):[t]).filter(e=>!a.some(t=>function(e,t){return e.id===t||e.text===t}(t,e)));s.length&&n.push({name:e.name,severity:"warning",message:`Value "${s.join(", ")}" does not match any declared option for "${e.name}".`})}}var a;return n}const Ot="/@pp-dev/variables-editor",Bt=new Set(["text","select","multiselect","file","list","color","boolean"]),Ct=/^[A-Za-z0-9_\s-]+$/,_t="Dist service or MiAPI is not defined",jt=new WeakMap;async function Nt(e){if(!e)return null;const t=await e.readPublicTemplateVariablesFile();if(!t)return null;try{const e=JSON.parse(t.toString("utf-8"));return e&&Array.isArray(e.tags)?e:null}catch{return null}}function Ht(e,t){const n=jt.get(e);if(n)return void(n.deps=t);const a={deps:t};jt.set(e,a),e.get("/@api/variables/schema",async(e,t)=>{const{distService:n}=a.deps;if(!n)return void t.status(503).json({error:_t});const s=await n.readPublicTemplateVariablesFile();if(!s)return void t.json({exists:!1,schema:null,raw:null});const o=s.toString("utf-8");try{t.json({exists:!0,schema:JSON.parse(o),raw:o})}catch(e){t.json({exists:!0,schema:null,raw:o,parseError:e instanceof Error?e.message:"Invalid JSON"})}}),e.put("/@api/variables/schema",async(e,t)=>{const{distService:n}=a.deps;if(!n)return void t.status(503).json({error:_t});const s=e.body?.raw;if("string"!=typeof s)return void t.status(400).json({error:"Request body must be { raw: string }."});let o;try{o=JSON.parse(s)}catch{return void t.status(400).json({error:"Not valid JSON."})}if("object"!=typeof o||null===o||Array.isArray(o))return void t.status(400).json({error:"The root value must be a JSON object."});const i=o.tags;if(!Array.isArray(i))return void t.status(400).json({error:'"tags" must be an array.'});const r=i.findIndex(e=>"object"!=typeof e||null===e||"string"!=typeof e.name||!e.name);if(-1!==r)return void t.status(400).json({error:`tags[${r}] is missing a non-empty "name".`});const l=[],c=new Set;for(const e of i)c.has(e.name)&&l.push(`Duplicate variable name "${e.name}".`),c.add(e.name),e.tag_type&&!Bt.has(e.tag_type)&&l.push(`"${e.name}" has an unrecognized tag_type "${e.tag_type}".`),Ct.test(e.name)||l.push(`"${e.name}" contains a character MI's own editor doesn't allow (only letters, digits, underscore, hyphen, and whitespace).`);await n.saveTemplateVariablesFile(Buffer.from(s,"utf-8")),t.json({ok:!0,warnings:l})}),e.get("/@api/variables/values",async(e,t)=>{const{distService:n,miAPI:s}=a.deps;try{const e=await Nt(n),a=await s.getLivePageVariables(),o=function(e,t){const n=new Map((e?.tags??[]).map(e=>[e.name,e])),a=new Map(t.map(e=>[e.name,e.value])),s=new Set([...n.keys(),...a.keys()]);return Array.from(s).map(e=>({name:e,value:a.get(e)??n.get(e)?.default_value??""}))}(e,a);t.json({schema:e,live:a,combined:o})}catch(e){t.status(502).json({error:"Failed to fetch live page variables.",details:e instanceof Error?e.message:String(e)})}}),e.put("/@api/variables/values",async(e,t)=>{const{distService:n,miAPI:s}=a.deps,o=e.body?.tags;if(Array.isArray(o)&&!o.some(e=>"object"!=typeof e||null===e||"string"!=typeof e.name||!e.name||"string"!=typeof e.value))try{const e=await Nt(n),a=new Map((e?.tags??[]).map(e=>[e.name,e])),i=[];for(const e of o){const t=a.get(e.name);t&&i.push(...$t(t,e.value))}await s.applyPageVariables(o),t.json({ok:!0,warnings:i})}catch(e){t.status(502).json({error:"Failed to save live page variables.",details:e instanceof Error?e.message:String(e)})}else t.status(400).json({error:"Request body must be { tags: {name, value}[] }."})}),e.get("/@api/variables/new-uid",(e,t)=>{t.json({uid:k("md5").update(S()).digest("hex")})}),e.get(Ot,(e,t)=>{var n;t.setHeader("Content-Type","text/html; charset=utf-8"),t.setHeader("Cache-Control","no-cache"),t.end(`<!DOCTYPE html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width,initial-scale=1">\n<title>Variables Editor — pp-dev</title>\n<script>\n// Set the stored theme override before first paint, so switching pages/reloading doesn't\n// flash the OS-default theme before settling on the user's explicit choice (see setTheme()).\ntry {\n var ppDevStoredTheme = localStorage.getItem('pp-dev-info-theme');\n\n if (ppDevStoredTheme === 'dark' || ppDevStoredTheme === 'light') {\n document.documentElement.setAttribute('data-pp-dev-theme', ppDevStoredTheme);\n }\n} catch (e) {}\n<\/script>\n<style>\n*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}\n:root,:root[data-pp-dev-theme="light"]{\n --bg:#ffffff;--bg2:#f4f5f7;--bg3:#e9ebef;--bg4:#dbdee3;\n --border:#d7dae0;--border2:#c2c6cd;\n --text:#1a1a1e;--text2:#54566b;--text3:#8a8c9c;\n --accent:#4f46e5;--accent2:#7c3aed;\n --green:#16a34a;--red:#dc2626;--yellow:#a16207;--blue:#2563eb;\n --btn-primary-fg:#ffffff;\n --font-mono:'Cascadia Code','Fira Code','JetBrains Mono',Consolas,monospace;\n --font-ui:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;\n}\n:root[data-pp-dev-theme="dark"]{\n --bg:#1a1a1e;--bg2:#222228;--bg3:#2a2a32;--bg4:#32323c;\n --border:#3a3a46;--border2:#4a4a58;\n --text:#e0e0f0;--text2:#a0a0b8;--text3:#606078;\n --accent:#6e8efb;--accent2:#a78bfa;\n --green:#4ade80;--red:#f87171;--yellow:#fbbf24;--blue:#60a5fa;\n --btn-primary-fg:#0d0d10;\n}\n@media (prefers-color-scheme: dark){\n :root{\n --bg:#1a1a1e;--bg2:#222228;--bg3:#2a2a32;--bg4:#32323c;\n --border:#3a3a46;--border2:#4a4a58;\n --text:#e0e0f0;--text2:#a0a0b8;--text3:#606078;\n --accent:#6e8efb;--accent2:#a78bfa;\n --green:#4ade80;--red:#f87171;--yellow:#fbbf24;--blue:#60a5fa;\n --btn-primary-fg:#0d0d10;\n }\n}\nhtml,body{height:100%;overflow:hidden;background:var(--bg);color:var(--text);font-family:var(--font-ui);font-size:13px;line-height:1.5}\n.app{display:flex;flex-direction:column;height:100vh}\n.toolbar{display:flex;align-items:center;gap:8px;padding:8px 12px;background:var(--bg2);border-bottom:1px solid var(--border);flex-shrink:0;flex-wrap:wrap}\n.toolbar-title{font-weight:600;font-size:14px;margin-right:8px}\n.tabs{display:flex;gap:4px}\n.tab{padding:5px 12px;border-radius:4px;border:1px solid transparent;background:transparent;color:var(--text2);font-size:12px;cursor:pointer}\n.tab:hover{background:var(--bg3);color:var(--text)}\n.tab.active{background:var(--bg4);color:var(--text);border-color:var(--border2)}\n.toolbar-spacer{flex:1}\n.ve-loading-indicator{display:inline-flex;align-items:center;gap:6px;color:var(--text3);font-size:11px}\n.ve-loading-dot{width:6px;height:6px;border-radius:50%;background:var(--accent);animation:ve-pulse 1s ease-in-out infinite}\n.ve-skeleton{padding:16px}\n.ve-skeleton-row{height:32px;border-radius:4px;background:var(--bg3);margin-bottom:8px;animation:ve-pulse 1.4s ease-in-out infinite}\n.ve-skeleton-row:nth-child(2){width:92%}\n.ve-skeleton-row:nth-child(3){width:96%}\n.ve-skeleton-row:nth-child(4){width:88%}\n.ve-skeleton-row:nth-child(5){width:94%}\n@keyframes ve-pulse{0%,100%{opacity:1}50%{opacity:.4}}\n.theme-switch{display:flex;gap:2px;background:var(--bg3);border:1px solid var(--border);border-radius:4px;padding:2px}\n.theme-switch button{padding:3px 8px;border:none;border-radius:3px;background:transparent;color:var(--text2);font-size:11px;cursor:pointer;font-family:var(--font-ui)}\n.theme-switch button.active{background:var(--accent);color:var(--btn-primary-fg)}\n.btn{padding:5px 12px;border-radius:4px;border:1px solid var(--border);background:var(--bg3);color:var(--text);font-size:12px;cursor:pointer;transition:background .1s}\n.btn:hover{background:var(--bg4);border-color:var(--border2)}\n.btn-primary{background:var(--accent);border-color:var(--accent);color:var(--btn-primary-fg);font-weight:600}\n.btn-primary:hover{opacity:.9;background:var(--accent)}\n.btn-sm{padding:3px 8px;font-size:11px}\n.content{flex:1;overflow:auto;padding:14px}\n.banner{margin-bottom:12px;padding:8px 12px;border-radius:4px;font-size:12px}\n.banner-warning{background:color-mix(in srgb, var(--yellow) 15%, var(--bg));color:var(--yellow);border:1px solid color-mix(in srgb, var(--yellow) 40%, var(--bg))}\n.banner-error{background:color-mix(in srgb, var(--red) 15%, var(--bg));color:var(--red);border:1px solid color-mix(in srgb, var(--red) 40%, var(--bg))}\n.banner-success{background:color-mix(in srgb, var(--green) 15%, var(--bg));color:var(--green);border:1px solid color-mix(in srgb, var(--green) 40%, var(--bg))}\n.banner ul{margin:6px 0 0 18px}\ntable{width:100%;border-collapse:collapse;font-size:12px}\nth{text-align:left;padding:6px 8px;color:var(--text2);border-bottom:1px solid var(--border);font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:.4px}\ntd{padding:5px 8px;border-bottom:1px solid var(--border);vertical-align:top}\ntr:hover td{background:var(--bg2)}\ninput[type=text],select,textarea{width:100%;background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:4px 6px;border-radius:4px;font-size:12px;font-family:var(--font-mono);outline:none}\ninput[type=text]:focus,select:focus,textarea:focus{border-color:var(--accent)}\ninput[type=color]{width:40px;height:26px;padding:0;border:1px solid var(--border);border-radius:4px;background:var(--bg3)}\ntextarea{resize:vertical;min-height:32px}\n.cell-textarea{min-height:56px}\n.col-name{width:180px}\n.col-type{width:130px}\n.col-source{width:110px}\n.col-actions{width:70px;text-align:right}\n.col-expand{width:26px;padding-left:8px!important;padding-right:0!important}\n.ve-expand-btn{width:22px;height:22px;padding:0;display:inline-flex;align-items:center;justify-content:center;font-size:13px;line-height:1;color:var(--text2)}\n.ve-expand-btn:hover{color:var(--text)}\n.raw-toggle{margin-bottom:10px}\n.raw-editor{width:100%;height:min(60vh,600px);font-family:var(--font-mono);font-size:12px;white-space:pre}\n.hint{color:var(--text3);font-size:11px;margin-top:4px}\n.details-row td{background:var(--bg2);padding:8px}\n.details-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px}\n.details-grid label{display:block;font-size:10px;color:var(--text3);text-transform:uppercase;margin-bottom:2px}\n.badge{font-size:10px;padding:1px 6px;border-radius:8px;background:var(--bg4);color:var(--text3)}\n.checkbox-row{display:flex;flex-wrap:wrap;gap:4px 16px}\n.details-grid .checkbox-label{display:inline-flex;align-items:center;gap:4px;font-size:11px;color:var(--text2);text-transform:none;margin-bottom:0}\n.checkbox-label input{width:auto}\n.empty-state{color:var(--text3);text-align:center;padding:40px}\n.ve-modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:100}\n.ve-modal{background:var(--bg2);border:1px solid var(--border2);border-radius:6px;padding:16px;width:min(400px,90vw);box-shadow:0 8px 24px rgba(0,0,0,.4)}\n.ve-modal-wide{width:min(560px,90vw)}\n.ve-modal-title{font-size:12px;color:var(--text2);margin-bottom:8px}\n.ve-modal-body{font-size:12px;line-height:1.6;max-height:60vh;overflow-y:auto}\n.ve-modal-body pre{background:var(--bg3);border:1px solid var(--border);border-radius:4px;padding:8px;font-family:var(--font-mono);font-size:11px;white-space:pre-wrap;word-break:break-word}\n.ve-modal-body code{background:var(--bg3);padding:1px 4px;border-radius:3px;font-family:var(--font-mono)}\n.ve-modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:12px}\n.ve-combo{position:relative}\n.ve-combo-input{margin-bottom:0}\n.ve-combo-list{position:absolute;left:0;right:0;top:100%;z-index:10;max-height:220px;overflow-y:auto;background:var(--bg3);border:1px solid var(--border2);border-radius:4px;margin-top:2px;box-shadow:0 4px 12px rgba(0,0,0,.3)}\n.ve-combo-option{padding:5px 8px;font-size:12px;cursor:pointer}\n.ve-combo-option:hover{background:var(--bg4)}\n.ve-combo-chips{display:flex;flex-wrap:wrap;gap:4px;margin-bottom:4px}\n.ve-chip{display:inline-flex;align-items:center;gap:4px;background:var(--bg4);border:1px solid var(--border2);border-radius:10px;padding:2px 4px 2px 8px;font-size:11px}\n.ve-chip button{background:none;border:none;color:var(--text3);cursor:pointer;font-size:11px;padding:0 2px}\n.ve-chip button:hover{color:var(--text)}\n.ve-list-items{display:flex;flex-direction:column;gap:6px}\n.ve-list-item{display:flex;gap:6px;align-items:flex-start}\n.ve-list-item > input[type=text]{flex:1}\n.ve-list-item-cols{background:var(--bg2);border:1px solid var(--border);border-radius:4px;padding:8px;display:flex;gap:8px;align-items:flex-start}\n.ve-list-item-fields{flex:1;display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:8px}\n.ve-list-field{display:flex;flex-direction:column;gap:2px}\n.ve-list-field label{font-size:10px;color:var(--text3);text-transform:uppercase}\n.ve-list-item-actions{flex-shrink:0}\n.ve-json-issues{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}\n.ve-json-issue-badge{display:inline-flex;align-items:center;gap:4px;background:color-mix(in srgb, var(--yellow) 15%, var(--bg));color:var(--yellow);border:1px solid color-mix(in srgb, var(--yellow) 40%, var(--bg));border-radius:10px;padding:2px 8px;font-size:11px;cursor:default}\n::-webkit-scrollbar{width:8px;height:8px}\n::-webkit-scrollbar-track{background:var(--bg)}\n::-webkit-scrollbar-thumb{background:var(--bg4);border-radius:4px}\n</style>\n</head>\n<body>\n<div class="app">\n <div class="toolbar">\n <span class="toolbar-title">🧩 Variables Editor</span>\n ${(n=a.deps.miAPI.isTemplateLess)?"":'<div class="tabs">\n <button class="tab" id="tab-schema" onclick="switchTab(\'schema\')">Schema</button>\n <button class="tab" id="tab-values" onclick="switchTab(\'values\')">Values</button>\n </div>'}\n ${n?"":'<span class="ve-loading-indicator" id="loading-indicator" style="display:none"><span class="ve-loading-dot"></span>Refreshing…</span>'}\n <div class="toolbar-spacer"></div>\n ${n?"":'<button class="btn btn-sm" onclick="refresh()">↻ Refresh</button>\n <button class="btn btn-sm btn-primary" id="save-btn" onclick="save()">Save</button>'}\n <div class="theme-switch" id="theme-switch">\n <button data-theme-choice="auto" aria-pressed="false" onclick="setTheme('auto')">Auto</button>\n <button data-theme-choice="dark" aria-pressed="false" onclick="setTheme('dark')">Dark</button>\n <button data-theme-choice="light" aria-pressed="false" onclick="setTheme('light')">Light</button>\n </div>\n </div>\n <div class="content" id="content">${n?'<div class="empty-state">This page has no associated template — there\'s no <code>__template_variables.json</code> and no page variables to edit here.</div>':""}</div>\n</div>\n<script>\n(function(){\n'use strict';\n\n// ── Theme (Auto/Dark/Light) ──────────────────────────────────────────────────\n// Runs unconditionally — before the templateLess early-return below — so the switcher works\n// even on the info-only page. The <head> inline script already set data-pp-dev-theme before\n// first paint; this just keeps it in sync with future picks and syncs the toolbar buttons'\n// state. Shares its localStorage key and attribute name with the dev panel and the Request\n// Inspector, so picking a theme in any of them carries over to the others.\nvar THEME_STORAGE_KEY = 'pp-dev-info-theme';\n\nfunction getStoredTheme() {\n try { return localStorage.getItem(THEME_STORAGE_KEY) || 'auto'; } catch (e) { return 'auto'; }\n}\n\nfunction applyTheme(theme) {\n if (theme === 'dark' || theme === 'light') {\n document.documentElement.setAttribute('data-pp-dev-theme', theme);\n } else {\n document.documentElement.removeAttribute('data-pp-dev-theme');\n }\n\n var switchEl = document.getElementById('theme-switch');\n\n if (switchEl) {\n Array.prototype.forEach.call(switchEl.children, function (btn) {\n var isActive = btn.dataset.themeChoice === theme;\n\n btn.classList.toggle('active', isActive);\n btn.setAttribute('aria-pressed', String(isActive));\n });\n }\n}\n\nfunction setTheme(theme) {\n try { localStorage.setItem(THEME_STORAGE_KEY, theme); } catch (e) {}\n\n applyTheme(theme);\n}\n\napplyTheme(getStoredTheme());\nwindow.setTheme = setTheme;\n\n// Server-rendered above: no tabs, no Refresh/Save, just the message — nothing here to wire up.\nif (${n?"true":"false"}) { return; }\n\nconst KNOWN_TYPES = ['text','select','multiselect','file','list','color','boolean'];\nconst KNOWN_TAG_SOURCES = ['static','page','element','folder','segment','dataset','dataset_data','announcement','group','category','custom_attribute','page_entity'];\n// select/multiselect sources whose options are hand-entered via additional_options rather than\n// loaded live from MI (the rest — dataset/announcement/group/category/custom_attribute/page/\n// page_entity — populate their own option list, so additional_options doesn't apply there).\nconst HAND_ENTERED_OPTION_SOURCES = ['static','segment','element','dataset_data'];\n// Matches MI's own "Create/Edit Variable" form: letters, digits, underscore, whitespace, hyphen.\n// Doubled backslash: this sits inside getVariablesEditorHtml()'s outer template literal, which\n// consumes one level of backslash-escaping when the .ts source is parsed — a bare s here would\n// collapse to a literal "s", silently dropping whitespace from the allowed character class (see\n// the identical pitfall fixed for escapeJsAttr() below).\nconst NAME_FORMAT_REGEX = /^[A-Za-z0-9_\\s-]+$/;\n// Lets a link like /@pp-dev/variables-editor?tab=values open directly on that tab.\nfunction tabFromUrl() {\n return new URLSearchParams(window.location.search).get('tab') === 'values' ? 'values' : 'schema';\n}\n\n// Values tab's raw/JSON mode is also linkable — /@pp-dev/variables-editor?tab=values&mode=json.\nfunction valuesJsonModeFromUrl() {\n return new URLSearchParams(window.location.search).get('mode') === 'json';\n}\n\nfunction setUrlState(tab, valuesJsonMode) {\n const url = new URL(window.location.href);\n\n url.searchParams.set('tab', tab);\n\n if (tab === 'values' && valuesJsonMode) {\n url.searchParams.set('mode', 'json');\n } else {\n url.searchParams.delete('mode');\n }\n\n history.replaceState(null, '', url);\n}\n\nlet activeTab = tabFromUrl();\nlet schemaState = null; // { exists, schema, raw, parseError }\nlet valuesState = null; // { schema, live, combined }\nlet schemaRows = []; // working copy of tags, edited in place\nconst invalidAdditionalOptionsRows = new WeakSet();\nlet valueRows = []; // working copy of {name, value}\nlet valuesRawMode = activeTab === 'values' && valuesJsonModeFromUrl();\nlet rawMode = false;\nlet dirty = false;\nlet editSeq = 0;\nlet banner = null;\n\n// Background-refresh bookkeeping, one pair of (loading flag, sequence counter) per tab: a\n// stale response (superseded by a newer load for the SAME tab before it resolved) is dropped\n// rather than clobbering fresher data — see loadTabData().\nlet schemaLoading = false;\nlet valuesLoading = false;\nlet schemaSeq = 0;\nlet valuesSeq = 0;\nlet autoLoadTimer = null;\nconst AUTO_LOAD_DEBOUNCE_MS = 200;\n\nconst contentEl = document.getElementById('content');\n\nwindow.addEventListener('beforeunload', (e) => {\n if (dirty) { e.preventDefault(); e.returnValue = ''; }\n});\n\nfunction setDirty(v) {\n dirty = v;\n\n if (v) { editSeq += 1; }\n}\n\nfunction escapeHtml(s) {\n return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');\n}\n\n// Escapes a value for embedding inside a single-quoted JS string literal that itself sits\n// inside an HTML attribute (e.g. onclick="fn('...')") — schema-derived names (column names,\n// tag names) aren't trusted local input, since the schema can come from a server backup ZIP\n// (see DistService.saveTemplateVariablesFile). Backslash/quote-escape for the JS-string\n// context first, then HTML-escape the result so it can't break out of the attribute either.\nfunction escapeJsAttr(s) {\n return escapeHtml(String(s).replace(/\\\\/g, '\\\\\\\\').replace(/'/g, "\\\\'"));\n}\n\nfunction showBanner(type, html, preserveContent) {\n banner = { type, html };\n\n if (preserveContent) {\n const existingBanner = contentEl.querySelector(':scope > .banner');\n const bannerEl = document.createElement('div');\n\n if (existingBanner) { existingBanner.remove(); }\n\n bannerEl.className = 'banner banner-' + type;\n bannerEl.innerHTML = html;\n contentEl.prepend(bannerEl);\n\n return;\n }\n\n render();\n}\n\nfunction clearBanner() { banner = null; }\n\n// Shown in place of a tab's content while it's never been loaded yet (first visit, or right\n// after a hard refresh of the page) — as opposed to ve-loading-indicator, which is for a\n// background reload of a tab that already has cached content on screen.\nfunction renderSkeleton() {\n let rows = '';\n\n for (let i = 0; i < 5; i++) { rows += '<div class="ve-skeleton-row"></div>'; }\n\n return '<div class="ve-skeleton">' + rows + '</div>';\n}\n\n// ── Data loading ─────────────────────────────────────────────────────────────\nasync function loadSchema(seq) {\n let data;\n\n try {\n const r = await fetch('/@api/variables/schema');\n\n data = await r.json();\n } catch (e) {\n if (seq !== schemaSeq) { return; }\n\n showBanner('error', 'Failed to load the schema: ' + escapeHtml(e.message));\n\n return;\n }\n\n if (seq !== schemaSeq) { return; }\n\n schemaState = data;\n schemaRows = (data.schema && Array.isArray(data.schema.tags)) ? data.schema.tags.map((t) => Object.assign({}, t)) : [];\n rawMode = data.exists && !data.schema;\n}\n\nasync function loadValues(seq) {\n let r;\n\n try {\n r = await fetch('/@api/variables/values');\n } catch (e) {\n if (seq !== valuesSeq) { return; }\n\n valuesState = null;\n valueRows = [];\n showBanner('error', 'Failed to load values: ' + escapeHtml(e.message));\n\n return;\n }\n\n if (!r.ok) {\n const err = await r.json().catch(() => ({}));\n\n if (seq !== valuesSeq) { return; }\n\n // A save is a full replacement. Keep the state explicitly unavailable after a failed\n // read so the user cannot accidentally submit an empty replacement set.\n valuesState = null;\n valueRows = [];\n showBanner('error', escapeHtml(err.error || 'Failed to load values.'));\n\n return;\n }\n\n let data;\n\n try {\n data = await r.json();\n } catch (e) {\n if (seq !== valuesSeq) { return; }\n\n valuesState = null;\n valueRows = [];\n showBanner('error', 'Failed to parse the values response: ' + escapeHtml(e.message));\n\n return;\n }\n\n if (seq !== valuesSeq) { return; }\n\n valuesState = data;\n valueRows = (data.combined || []).map((e) => Object.assign({}, e));\n}\n\n// Fetches fresh data for one tab, tracking a per-tab "loading" flag and sequence number so a\n// response that's superseded by a newer load for the SAME tab (started before this one\n// resolved) gets dropped instead of clobbering fresher data. Toggles the tab's own loading\n// flag but deliberately doesn't call render() itself — callers decide when to repaint.\nfunction loadTabData(tab) {\n if (tab === 'schema') {\n const seq = ++schemaSeq;\n\n schemaLoading = true;\n\n return loadSchema(seq).then(() => {\n if (seq === schemaSeq) { schemaLoading = false; }\n });\n }\n\n const seq = ++valuesSeq;\n\n valuesLoading = true;\n\n return loadValues(seq).then(() => {\n if (seq === valuesSeq) { valuesLoading = false; }\n });\n}\n\n// The manual "↻ Refresh" button — deliberately NOT debounced, so it's always the fast path\n// for "I just changed something in MI and want it now" (see scheduleAutoLoad for the\n// debounced, tab-switch-driven path).\nasync function doRefresh() {\n clearBanner();\n setDirty(false);\n\n // loadTabData() flips the tab's loading flag synchronously before its fetch resolves, so\n // render() has to run AFTER that call starts (not before) for the indicator to reflect it.\n const pending = loadTabData(activeTab);\n\n render();\n await pending;\n render();\n}\n\nfunction refresh() {\n if (dirty) {\n showConfirmModal('Discard unsaved changes?', doRefresh);\n\n return;\n }\n\n doRefresh();\n}\n\n// Debounces the network round-trip that automatically follows a tab switch: rapidly flipping\n// between tabs (or landing on one only to immediately leave it) only fires the load that's\n// still pending once things settle, rather than one request per click.\nfunction scheduleAutoLoad(tab) {\n if (autoLoadTimer) { clearTimeout(autoLoadTimer); }\n\n autoLoadTimer = setTimeout(() => {\n autoLoadTimer = null;\n\n // loadTabData() flips the tab's loading flag synchronously before its fetch resolves, so\n // render() has to run AFTER this call starts (not before) for the indicator to reflect it.\n const pending = loadTabData(tab);\n\n if (activeTab === tab) { render(); }\n\n pending.then(() => {\n if (activeTab === tab) { render(); }\n });\n }, AUTO_LOAD_DEBOUNCE_MS);\n}\n\nfunction doSwitchTab(tab) {\n activeTab = tab;\n setDirty(false);\n clearBanner();\n\n // Switching tabs by hand always starts the Values tab in table view — only a direct link\n // (?tab=values&mode=json, handled at bootstrap) opens straight into raw/JSON mode.\n if (tab === 'values') { valuesRawMode = false; }\n\n setUrlState(tab, valuesRawMode);\n\n document.getElementById('tab-schema').classList.toggle('active', tab === 'schema');\n document.getElementById('tab-values').classList.toggle('active', tab === 'values');\n\n // Paint immediately with whatever's already cached for this tab (or a skeleton, if it's\n // never been loaded) instead of leaving the previous tab's content on screen until the\n // debounced fetch below resolves.\n render();\n scheduleAutoLoad(tab);\n}\n\nfunction switchTab(tab) {\n if (dirty) {\n showConfirmModal('Discard unsaved changes and switch tabs?', () => doSwitchTab(tab));\n\n return;\n }\n\n doSwitchTab(tab);\n}\n\n// ── Schema tab ───────────────────────────────────────────────────────────────\n\n// A styled <select> (not a native <datalist>, whose popup/selected-state styling browsers\n// won't let us theme) with an escape hatch: picking "Custom…" prompts for any string, so a\n// tag_type/tag_source MI adds in the future can be configured here without a pp-dev code change.\nconst CUSTOM_TYPE_SENTINEL = '__custom__';\n\nfunction customSelectCell(field, knownValues, fallback, tag, i) {\n const currentValue = tag[field] || fallback;\n const isKnown = knownValues.includes(currentValue);\n const knownOptions = knownValues.map((v) =>\n '<option value="' + v + '"' + (currentValue === v ? ' selected' : '') + '>' + v + '</option>',\n ).join('');\n const customOption = !currentValue || isKnown\n ? ''\n : '<option value="' + escapeHtml(currentValue) + '" selected>' + escapeHtml(currentValue) + ' (custom)</option>';\n\n return (\n '<select onchange="onCustomSelectChange(' + i + ',this,\\'' + field + '\\')">' +\n knownOptions +\n customOption +\n '<option value="' + CUSTOM_TYPE_SENTINEL + '">Custom…</option>' +\n '</select>'\n );\n}\n\nfunction typeSelectCell(tag, i) {\n return customSelectCell('tag_type', KNOWN_TYPES, 'text', tag, i);\n}\n\nfunction tagSourceSelectCell(tag, i) {\n return customSelectCell('tag_source', KNOWN_TAG_SOURCES, '', tag, i);\n}\n\n// Short, one-line version for inline display next to the field — click "?" for the full\n// per-tag_source breakdown (additionalOptionsDetails()) with examples, shown in a modal.\nfunction additionalOptionsHint(tag) {\n const tagType = tag.tag_type;\n\n if (tagType === 'select' || tagType === 'multiselect') {\n const source = tag.tag_source || 'static';\n\n if (source === 'static') { return 'The option list itself.'; }\n if (source === 'dataset_data') { return 'Which dataset + columns to pull options from.'; }\n if (source === 'element') { return 'Optional filter by element type.'; }\n if (source === 'segment') { return 'Accepted by MI\\'s form, but not read for this source.'; }\n\n return 'Not used for tag_source "' + escapeHtml(source) + '" — MI loads its options live.';\n }\n\n if (tagType === 'list') {\n return 'Column definitions for list-of-objects items, or "" for a flat list of strings.';\n }\n\n if (tagType === 'boolean') {\n return 'Not used for tag_type "boolean".';\n }\n\n return 'No confirmed runtime use for tag_type "' + escapeHtml(tagType || 'text') + '".';\n}\n\n// Full breakdown with examples, reverse-engineered from MI's own options-loading logic —\n// shown in a modal (showAdditionalOptionsHelp()) so the inline hint above can stay short.\nfunction additionalOptionsDetails(tag) {\n const tagType = tag.tag_type;\n\n if (tagType === 'select' || tagType === 'multiselect') {\n const source = tag.tag_source || 'static';\n\n if (source === 'static') {\n return '<p>The option list itself. Each entry is a plain string, or an object with <code>id</code>/<code>text</code>:</p>' +\n '<pre>[{"id":"1","text":"One"},"Two"]</pre>' +\n '<p>A plain string entry uses itself as both the stored value and the label.</p>';\n }\n\n if (source === 'dataset_data') {\n return '<p>Pulls options live from a dataset, by column name:</p>' +\n '<pre>{"dataset_id":123,"key_column":"id_col","text_column":"name_col"}</pre>' +\n '<p><code>key_column</code> supplies the option value, <code>text_column</code> the label.</p>';\n }\n\n if (source === 'element') {\n return '<p>Optional — restricts the dashboard-element list to one type:</p>' +\n '<pre>{"type":"metric"}</pre>' +\n '<p>Accepted values: <code>metric</code>, <code>multi-metric chart</code>, <code>internal report</code>, <code>external report</code>, <code>other external content</code>. Leave empty to list all elements.</p>';\n }\n\n if (source === 'segment') {\n return '<p>MI\\'s own "Create/Edit Variable" form shows this field for tag_source "segment", but its options-loading endpoint never actually reads it — the segment list always comes back unfiltered. Best left empty.</p>';\n }\n\n return '<p>Not used — for tag_source "' + escapeHtml(source) + '", MI loads options live from its own data (e.g. the dataset/announcement/group/category/page list) and never consults this field.</p>';\n }\n\n if (tagType === 'list') {\n return '<p>An array of column definitions — each list item becomes an object keyed by these names:</p>' +\n '<pre>[{"name":"id","type":"textarea"},{"name":"color","type":"color"}]</pre>' +\n '<p>A bare string entry is shorthand for <code>{"name": <that string>, "type": "textarea"}</code>. Leave as <code>""</code> (empty string) instead for a flat list of plain strings.</p>' +\n '<p>Supported field <code>type</code>s: <code>textarea</code>, <code>color</code>, <code>select</code>, <code>multi-select</code>, <code>file</code>.</p>' +\n '<p>For a <code>select</code>/<code>multi-select</code> column, add <code>source</code> (defaults to <code>"static"</code>) and <code>options</code> — same per-source rules as the tag-level <code>tag_source</code>/<code>additional_options</code> above, just scoped to this one column and fed by <code>options</code> instead:</p>' +\n '<pre>{"name":"country","type":"select","options":["USA","Canada"]}</pre>' +\n '<p>MI\\'s own list-item editor never reads this column\\'s own <code>additional_options</code> — only <code>options</code> — so leave it out.</p>';\n }\n\n if (tagType === 'boolean') {\n return '<p>MI\\'s own editor doesn\\'t show this field for tag_type "boolean", and there\\'s no known runtime use for it — leave empty.</p>';\n }\n\n return '<p>MI\\'s own editor lets you fill this in for tag_type "' + escapeHtml(tagType || 'text') + '", but there\\'s no confirmed runtime consumer for it — treat it as unused/reserved.</p>';\n}\n\nfunction showAdditionalOptionsHelp(i) {\n const tag = schemaRows[i];\n\n showInfoModal('additional_options — ' + escapeHtml(tag.tag_type || 'text') + (tag.tag_source ? ' / ' + escapeHtml(tag.tag_source) : ''), additionalOptionsDetails(tag));\n}\n\n// Mirrors MI's own "Create/Edit Variable" form: which fields it shows/hides depends on\n// tag_type (and, for the tag_source picker and additional_options, tag_source too). For a\n// tag_type MI hasn't defined yet (i.e. a custom one entered via typeSelectCell's escape hatch),\n// there's no such logic to mirror, so give full access to everything instead of guessing.\nfunction fieldVisibility(tag) {\n const type = tag.tag_type;\n\n if (!KNOWN_TYPES.includes(type)) {\n return { tagSource: true, useHtml: true, rawHtml: true, useJson: true, defaultValue: true, additionalOptions: true };\n }\n\n const isTextOrList = type === 'text' || type === 'list';\n const isSelectLike = type === 'select' || type === 'multiselect';\n const useHtmlOn = tag.use_hmtl_editor_ind === 'Y';\n const source = tag.tag_source || 'static';\n\n return {\n tagSource: isSelectLike,\n useHtml: type === 'text',\n rawHtml: isTextOrList && !useHtmlOn,\n useJson: (type === 'text' && !useHtmlOn) || type === 'list',\n defaultValue: isTextOrList,\n additionalOptions:\n type === 'text' || type === 'file' || type === 'list' || type === 'color' ||\n (isSelectLike && HAND_ENTERED_OPTION_SOURCES.indexOf(source) !== -1),\n };\n}\n\nfunction checkboxField(label, checked, onchangeAttr) {\n return '<label class="checkbox-label"><input type="checkbox" ' + (checked ? 'checked' : '') + ' ' + onchangeAttr + ' />' + escapeHtml(label) + '</label>';\n}\n\nfunction editorFlagsRow(tag, i, vis) {\n if (!vis.useHtml && !vis.rawHtml && !vis.useJson) {\n return '';\n }\n\n const parts = [];\n\n if (vis.useHtml) {\n parts.push(checkboxField('Use WYSIWYG Editor', tag.use_hmtl_editor_ind === 'Y', 'onchange="updateEditorFlag(' + i + ',\\'use_hmtl_editor_ind\\',this.checked)"'));\n }\n\n if (vis.rawHtml) {\n parts.push(checkboxField('Raw HTML', tag.use_raw_html_ind === 'Y', 'onchange="updateEditorFlag(' + i + ',\\'use_raw_html_ind\\',this.checked)"'));\n }\n\n if (vis.useJson) {\n parts.push(checkboxField('Use JSON Editor', tag.use_json_editor_ind === 'Y', 'onchange="updateEditorFlag(' + i + ',\\'use_json_editor_ind\\',this.checked)"'));\n }\n\n return '<div style="grid-column:1/-1" class="checkbox-row">' + parts.join('') + '</div>';\n}\n\nfunction nameWarningHint(name) {\n if (!name) {\n return '<div class="hint" style="color:var(--red)">Name is required.</div>';\n }\n\n if (!NAME_FORMAT_REGEX.test(name)) {\n return '<div class="hint" style="color:var(--red)">Contains a character MI\\'s own editor doesn\\'t allow — only letters, digits, underscore, hyphen, and whitespace.</div>';\n }\n\n return '';\n}\n\n// Re-renders the whole schema table (needed because changing tag_type/tag_source/an editor\n// flag can change which OTHER fields this same row should show), then restores row i's\n// advanced panel to whatever open/closed state it was in — render() always starts it closed.\nfunction rerenderKeepingAdvancedOpen(i) {\n const before = document.getElementById('advanced-' + i);\n const wasOpen = !!before && before.style.display !== 'none';\n\n render();\n\n if (wasOpen) {\n const after = document.getElementById('advanced-' + i);\n const btn = document.getElementById('expand-btn-' + i);\n\n if (after) { after.style.display = ''; }\n if (btn) { btn.textContent = '▾'; btn.setAttribute('aria-expanded', 'true'); }\n }\n}\n\nfunction updateEditorFlag(i, field, checked) {\n schemaRows[i][field] = checked ? 'Y' : 'N';\n\n // Matches MI's own form: turning WYSIWYG on is exclusive with Raw HTML / JSON Editor.\n if (field === 'use_hmtl_editor_ind' && checked) {\n schemaRows[i].use_raw_html_ind = 'N';\n schemaRows[i].use_json_editor_ind = 'N';\n }\n\n setDirty(true);\n rerenderKeepingAdvancedOpen(i);\n}\n\n// A custom HTML modal instead of window.prompt() — some embedded webviews (e.g. Cursor's\n// built-in browser) don't implement native prompt()/confirm() at all, so it just returns null\n// immediately with no dialog ever shown. This works identically everywhere.\nfunction showCustomValueModal(title, initialValue, onDone) {\n const overlay = document.createElement('div');\n\n overlay.className = 've-modal-overlay';\n overlay.innerHTML =\n '<div class="ve-modal">' +\n '<div class="ve-modal-title">' + escapeHtml(title) + '</div>' +\n '<input type="text" class="ve-modal-input" value="' + escapeHtml(initialValue) + '" />' +\n '<div class="ve-modal-actions">' +\n '<button type="button" class="btn btn-sm ve-modal-cancel">Cancel</button>' +\n '<button type="button" class="btn btn-sm btn-primary ve-modal-ok">OK</button>' +\n '</div>' +\n '</div>';\n\n document.body.appendChild(overlay);\n\n const inputEl = overlay.querySelector('.ve-modal-input');\n\n inputEl.focus();\n inputEl.select();\n\n function close(value) {\n overlay.remove();\n onDone(value);\n }\n\n overlay.querySelector('.ve-modal-ok').addEventListener('click', () => close(inputEl.value));\n overlay.querySelector('.ve-modal-cancel').addEventListener('click', () => close(null));\n overlay.addEventListener('click', (ev) => {\n if (ev.target === overlay) {\n close(null);\n }\n });\n inputEl.addEventListener('keydown', (ev) => {\n if (ev.key === 'Enter') {\n ev.preventDefault();\n close(inputEl.value);\n } else if (ev.key === 'Escape') {\n ev.preventDefault();\n close(null);\n }\n });\n}\n\n// Same rationale as showCustomValueModal() — window.confirm() is unreliable in some embedded\n// webviews, so any "discard unsaved changes?" gate needs to go through this instead.\nfunction showConfirmModal(message, onConfirm) {\n const overlay = document.createElement('div');\n\n overlay.className = 've-modal-overlay';\n overlay.innerHTML =\n '<div class="ve-modal">' +\n '<div class="ve-modal-title">' + escapeHtml(message) + '</div>' +\n '<div class="ve-modal-actions">' +\n '<button type="button" class="btn btn-sm ve-modal-cancel">Cancel</button>' +\n '<button type="button" class="btn btn-sm btn-primary ve-modal-ok">OK</button>' +\n '</div>' +\n '</div>';\n\n document.body.appendChild(overlay);\n overlay.querySelector('.ve-modal-ok').focus();\n\n function close(confirmed) {\n overlay.remove();\n\n if (confirmed) {\n onConfirm();\n }\n }\n\n overlay.querySelector('.ve-modal-ok').addEventListener('click', () => close(true));\n overlay.querySelector('.ve-modal-cancel').addEventListener('click', () => close(false));\n overlay.addEventListener('click', (ev) => {\n if (ev.target === overlay) {\n close(false);\n }\n });\n overlay.addEventListener('keydown', (ev) => {\n if (ev.key === 'Enter') {\n ev.preventDefault();\n close(true);\n } else if (ev.key === 'Escape') {\n ev.preventDefault();\n close(false);\n }\n });\n}\n\n// A plain info modal (no input, single "Close" action) — used to keep long explanations\n// (e.g. additional_options' full per-tag_source breakdown) out of the inline hint text.\nfunction showInfoModal(title, bodyHtml) {\n const overlay = document.createElement('div');\n\n overlay.className = 've-modal-overlay';\n overlay.innerHTML =\n '<div class="ve-modal ve-modal-wide">' +\n '<div class="ve-modal-title">' + escapeHtml(title) + '</div>' +\n '<div class="ve-modal-body">' + bodyHtml + '</div>' +\n '<div class="ve-modal-actions">' +\n '<button type="button" class="btn btn-sm btn-primary ve-modal-ok">Close</button>' +\n '</div>' +\n '</div>';\n\n document.body.appendChild(overlay);\n overlay.querySelector('.ve-modal-ok').focus();\n\n function close() { overlay.remove(); }\n\n overlay.querySelector('.ve-modal-ok').addEventListener('click', close);\n overlay.addEventListener('click', (ev) => {\n if (ev.target === overlay) { close(); }\n });\n overlay.addEventListener('keydown', (ev) => {\n if (ev.key === 'Enter' || ev.key === 'Escape') {\n ev.preventDefault();\n close();\n }\n });\n}\n\nfunction onCustomSelectChange(i, selectEl, field) {\n if (selectEl.value !== CUSTOM_TYPE_SENTINEL) {\n updateSchemaField(i, field, selectEl.value);\n\n // tag_type/tag_source changes can change which other fields this row should show.\n rerenderKeepingAdvancedOpen(i);\n\n return;\n }\n\n showCustomValueModal('Custom ' + field + ' value', schemaRows[i][field] || '', (custom) => {\n if (custom) {\n updateSchemaField(i, field, custom);\n }\n\n // Re-render either way: on success, so the select shows the new custom option instead of\n // "Custom…"; on cancel, so it reverts to the row's actual (unchanged) value.\n rerenderKeepingAdvancedOpen(i);\n });\n}\n\nfunction renderSchemaTab() {\n if (!schemaState) { return renderSkeleton(); }\n\n if (rawMode) {\n return \`\n <div class="raw-toggle"><button class="btn btn-sm" onclick="toggleRawMode()">Switch to table view</button></div>\n \${schemaState.parseError ? '<div class="banner banner-error">File exists but is not valid JSON: ' + escapeHtml(schemaState.parseError) + '</div>' : ''}\n <textarea class="raw-editor" id="raw-editor" oninput="setDirty(true)">\${escapeHtml(schemaState.raw || '')}</textarea>\n <div class="hint">Editing the raw file text directly. Must be valid JSON with a top-level "tags" array.</div>\n \`;\n }\n\n if (!schemaState.exists) {\n return \`<div class="empty-state">No local __template_variables.json found.<br><button class="btn btn-sm" style="margin-top:10px" onclick="addSchemaRow()">Create one — add first variable</button></div>\`;\n }\n\n const rows = schemaRows.map((tag, i) => {\n const vis = fieldVisibility(tag);\n // An empty string means "not set" (the flat-list/no-options convention) — show it as an\n // empty field instead of the literal two-character JSON string '""'.\n const additionalOptionsRaw = tag.additional_options !== undefined && tag.additional_options !== ''\n ? JSON.stringify(tag.additional_options)\n : '';\n\n const defaultValueCell = vis.defaultValue\n ? '<textarea class="cell-textarea" oninput="updateSchemaField(' + i + ',\\'default_value\\',this.value)">' + escapeHtml(tag.default_value || '') + '</textarea>'\n : '<textarea class="cell-textarea" disabled title="MI\\'s own editor has no Default Value field for this tag_type.">' + escapeHtml(tag.default_value || '') + '</textarea>';\n\n const tagSourceCell = vis.tagSource\n ? tagSourceSelectCell(tag, i)\n : '<span class="badge">' + escapeHtml(tag.tag_source || 'static') + '</span> <div class="hint">Only configurable for select/multiselect in MI\\'s own editor.</div>';\n\n const additionalOptionsCell = vis.additionalOptions\n ? '<textarea oninput="updateSchemaAdditionalOptions(' + i + ',this.value)">' + escapeHtml(additionalOptionsRaw) + '</textarea>'\n : '<textarea disabled title="Not used for this tag_type/tag_source combination in MI\\'s own editor.">' + escapeHtml(additionalOptionsRaw) + '</textarea>';\n\n return \`\n <tr>\n <td class="col-expand">\n <button class="btn btn-sm ve-expand-btn" id="expand-btn-\${i}" onclick="toggleSchemaAdvanced(\${i})" aria-expanded="false" title="Advanced fields (uid, tag_source, additional_options, editor flags)">▸</button>\n </td>\n <td class="col-name">\n <input type="text" value="\${escapeHtml(tag.name || '')}" oninput="updateSchemaField(\${i},'name',this.value)" />\n \${nameWarningHint(tag.name)}\n </td>\n <td class="col-type">\${typeSelectCell(tag, i)}</td>\n <td>\${defaultValueCell}</td>\n <td><textarea class="cell-textarea" oninput="updateSchemaField(\${i},'description',this.value)">\${escapeHtml(tag.description || '')}</textarea></td>\n <td class="col-actions">\n <button class="btn btn-sm" onclick="removeSchemaRow(\${i})">✕</button>\n </td>\n </tr>\n <tr class="details-row" id="advanced-\${i}" style="display:none">\n <td colspan="6">\n <div class="details-grid">\n <div><label>uid</label><input type="text" value="\${escapeHtml(tag.uid || '')}" oninput="updateSchemaField(\${i},'uid',this.value)" /></div>\n <div><label>tag_source</label>\${tagSourceCell}</div>\n \${editorFlagsRow(tag, i, vis)}\n <div style="grid-column:1/-1">\n <label>additional_options (raw JSON) <button type="button" class="btn btn-sm" style="text-transform:none;padding:0 6px;margin-left:4px" onclick="showAdditionalOptionsHelp(\${i})" title="What goes here for this type/source?">?</button></label>\n \${additionalOptionsCell}\n <div class="hint">\${additionalOptionsHint(tag)}</div>\n </div>\n </div>\n </td>\n </tr>\n \`;\n }).join('');\n\n return \`\n <div class="raw-toggle"><button class="btn btn-sm" onclick="toggleRawMode()">View/edit raw JSON</button></div>\n <table>\n <thead><tr><th class="col-expand"></th><th class="col-name">Name</th><th class="col-type">Type</th><th>Default value</th><th>Description</th><th class="col-actions"></th></tr></thead>\n <tbody>\${rows || '<tr><td colspan="6" class="empty-state">No variables yet.</td></tr>'}</tbody>\n </table>\n <button class="btn btn-sm" style="margin-top:10px" onclick="addSchemaRow()">+ Add variable</button>\n <div class="hint">Pick a known type, or choose "Custom…" to enter any value if MI adds a new tag_type.</div>\n \`;\n}\n\nfunction toggleRawMode() {\n if (!rawMode) {\n // Entering raw mode: serialize the current table state so edits aren't lost.\n schemaState.raw = JSON.stringify(Object.assign({}, schemaState.schema, { tags: schemaRows }), null, 2);\n } else {\n // Leaving raw mode: try to parse back into the table; keep raw mode on failure.\n const editorEl = document.getElementById('raw-editor');\n const editorText = editorEl ? editorEl.value : schemaState.raw;\n\n try {\n const parsed = JSON.parse(editorText);\n\n if (!parsed || !Array.isArray(parsed.tags)) { throw new Error('Missing "tags" array'); }\n\n schemaState.schema = parsed;\n schemaRows = parsed.tags.map((t) => Object.assign({}, t));\n schemaState.exists = true;\n delete schemaState.parseError;\n } catch (e) {\n // showBanner() re-renders, which would otherwise regenerate this textarea from the last\n // known-good schemaState.raw — put the user's (still-broken) text back afterward so their\n // in-progress edit isn't silently discarded.\n showBanner('error', 'Cannot switch to table view: ' + escapeHtml(e.message));\n\n const freshEditorEl = document.getElementById('raw-editor');\n\n if (freshEditorEl) { freshEditorEl.value = editorText; }\n\n return;\n }\n }\n\n rawMode = !rawMode;\n render();\n}\n\nfunction updateSchemaField(i, field, value) {\n schemaRows[i][field] = value;\n setDirty(true);\n}\n\nfunction updateSchemaAdditionalOptions(i, text) {\n try {\n schemaRows[i].additional_options = text.trim() ? JSON.parse(text) : '';\n invalidAdditionalOptionsRows.delete(schemaRows[i]);\n } catch {\n schemaRows[i].additional_options = text;\n invalidAdditionalOptionsRows.add(schemaRows[i]);\n }\n\n setDirty(true);\n}\n\nfunction toggleSchemaAdvanced(i) {\n const row = document.getElementById('advanced-' + i);\n const btn = document.getElementById('expand-btn-' + i);\n const nowOpen = row.style.display === 'none';\n\n row.style.display = nowOpen ? '' : 'none';\n\n if (btn) {\n btn.textContent = nowOpen ? '▾' : '▸';\n btn.setAttribute('aria-expanded', String(nowOpen));\n }\n}\n\n// Fallback only (network hiccup) — the server generates a real MD5 via /@api/variables/new-uid,\n// since Node's crypto has real MD5 and the browser doesn't. This is just same-shaped filler.\nfunction fallbackUid() {\n var s = '';\n\n while (s.length < 32) {\n s += Math.random().toString(16).slice(2);\n }\n\n return s.slice(0, 32);\n}\n\nasync function addSchemaRow() {\n var uid = fallbackUid();\n\n try {\n var r = await fetch('/@api/variables/new-uid');\n var data = await r.json();\n\n if (data && typeof data.uid === 'string') {\n uid = data.uid;\n }\n } catch (e) {\n // keep the fallback uid\n }\n\n schemaState.exists = true;\n schemaRows.push({\n name: 'new_variable_' + (schemaRows.length + 1),\n uid: uid,\n tag_type: 'text',\n tag_source: 'static',\n default_value: '',\n additional_options: '',\n description: '',\n });\n setDirty(true);\n render();\n}\n\nfunction removeSchemaRow(i) {\n const name = (schemaRows[i] && schemaRows[i].name) || '(unnamed)';\n\n showConfirmModal('Delete variable "' + name + '"? This only takes effect once you Save.', () => {\n schemaRows.splice(i, 1);\n setDirty(true);\n render();\n });\n}\n\nasync function saveSchema() {\n let raw;\n\n if (rawMode) {\n raw = document.getElementById('raw-editor').value;\n } else {\n const invalidAdditionalOptionsIndex = schemaRows.findIndex((row) => invalidAdditionalOptionsRows.has(row));\n\n if (invalidAdditionalOptionsIndex !== -1) {\n const tagName = schemaRows[invalidAdditionalOptionsIndex].name || ('row ' + (invalidAdditionalOptionsIndex + 1));\n\n showBanner('error', 'additional_options must be valid JSON for "' + escapeHtml(tagName) + '".');\n\n return;\n }\n\n raw = JSON.stringify(Object.assign({}, schemaState.schema, { tags: schemaRows }), null, 2);\n }\n\n const saveEditSeq = editSeq;\n const r = await fetch('/@api/variables/schema', {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ raw }),\n });\n const data = await r.json();\n\n if (!r.ok) {\n showBanner('error', escapeHtml(data.error || 'Save failed.'));\n\n return;\n }\n\n const warnings = data.warnings || [];\n const hasNewerEdits = editSeq !== saveEditSeq;\n const message = warnings.length\n ? 'Saved, with warning(s):<ul>' + warnings.map((w) => '<li>' + escapeHtml(w) + '</li>').join('') + '</ul>'\n : 'Schema saved.';\n\n if (hasNewerEdits) {\n showBanner('warning', message + '<div>Newer edits remain unsaved.</div>', true);\n\n return;\n }\n\n setDirty(false);\n showBanner(warnings.length ? 'warning' : 'success', message);\n\n await loadTabData('schema');\n render();\n}\n\n// ── Values tab ───────────────────────────────────────────────────────────────\nfunction schemaTagFor(name) {\n const tags = (valuesState.schema && valuesState.schema.tags) || [];\n\n return tags.find((t) => t.name === name);\n}\n\n// Not full markdown (MI itself renders this as markdown) — a lightweight, dependency-free\n// line-break-preserving plain-text display is enough context to have here.\nfunction descriptionHint(tag) {\n if (!tag || !tag.description) { return ''; }\n\n return '<div class="hint" style="white-space:pre-wrap">' + escapeHtml(tag.description) + '</div>';\n}\n\n// The plain-text default already matches MI's own fallback (a multiline input); on top of\n// that, mirror the three editor-flag checkboxes from the Schema tab's Advanced panel — MI's\n// own value editor swaps in a rich/code editor for the first two (we use a plain textarea for\n// both, since embedding a real WYSIWYG editor is out of scope for this lightweight tool). The\n// third (use_json_editor_ind) is accepted by MI's create form but never actually acted upon by\n// its value editor either — shown here only as an FYI, no widget change.\nfunction textValueCell(tag, row, i) {\n const widget = '<textarea class="cell-textarea" oninput="updateValueField(' + i + ', this.value)">' + escapeHtml(row.value) + '</textarea>';\n let note = '';\n\n if (tag.use_hmtl_editor_ind === 'Y') {\n note = 'WYSIWYG HTML in MI — edited as raw HTML here.';\n } else if (tag.use_raw_html_ind === 'Y') {\n note = 'Raw HTML — MI skips XSS-encoding this value on save.';\n } else if (tag.use_json_editor_ind === 'Y') {\n note = 'JSON Editor flag is set, though MI\\'s value editor doesn\\'t actually act on it.';\n }\n\n return note ? widget + '<div class="hint">' + note + '</div>' : widget;\n}\n\nfunction normalizeStaticOptions(tag) {\n const opts = tag && tag.additional_options;\n\n if (!Array.isArray(opts)) { return []; }\n\n return opts.map((o) => (typeof o === 'string' ? { id: o, text: o } : { id: String(o.id), text: String(o.text != null ? o.text : o.id) }));\n}\n\nfunction labelForOption(options, id) {\n const found = options.find((o) => o.id === id);\n\n return found ? found.text : id;\n}\n\nfunction comboOptionsHtml(options) {\n return options.map((o) =>\n '<div class="ve-combo-option" data-id="' + escapeHtml(o.id) + '" data-text="' + escapeHtml(o.text) + '" onmousedown="event.preventDefault();onComboOptionPick(this)">' +\n (o.text !== o.id ? escapeHtml(o.text) + ' <span class="hint">(' + escapeHtml(o.id) + ')</span>' : escapeHtml(o.id)) +\n '</div>',\n ).join('');\n}\n\nfunction chipHtml(id, text) {\n return '<span class="ve-chip" data-id="' + escapeHtml(id) + '">' + escapeHtml(text) + '<button type="button" onmousedown="event.preventDefault()" onclick="onChipRemove(this)">✕</button></span>';\n}\n\n// A search-as-you-type combobox — not a native <select>/<datalist> (see the Schema tab's\n// tag_type picker for why: unstylable popups, broken selected-state colors). Only viable for\n// tag_source "static", since that's the only source whose options live in additional_options\n// itself; every other source needs MI's own internal editor-only options endpoint, which we\n// deliberately don't call (no public API for it — see TEMPLATE_VARIABLES.md).\nfunction selectValueWidget(tag, row, i) {\n const options = normalizeStaticOptions(tag);\n const isMulti = tag.tag_type === 'multiselect';\n\n if (isMulti) {\n const ids = row.value ? row.value.split(',').filter(Boolean) : [];\n const chips = ids.map((id) => chipHtml(id, labelForOption(options, id))).join('');\n\n return '<div class="ve-combo ve-combo-multi">' +\n '<div class="ve-combo-chips" data-row="' + i + '" id="combo-chips-' + i + '">' + chips + '</div>' +\n '<input type="text" class="ve-combo-input" placeholder="Search by id or value…" oninput="onComboFilter(' + i + ',this.value)" onfocus="onComboFocus(' + i + ')" onblur="onComboBlur(' + i + ')" />' +\n '<div class="ve-combo-list" id="combo-list-' + i + '" style="display:none">' + comboOptionsHtml(options) + '</div>' +\n '</div>';\n }\n\n return '<div class="ve-combo">' +\n '<input type="text" class="ve-combo-input" value="' + escapeHtml(row.value || '') + '" placeholder="Search by id or value…" oninput="onSingleComboInput(' + i + ',this.value)" onfocus="onComboFocus(' + i + ')" onblur="onComboBlur(' + i + ')" />' +\n '<div class="ve-combo-list" id="combo-list-' + i + '" style="display:none">' + comboOptionsHtml(options) + '</div>' +\n '</div>';\n}\n\nfunction onComboFocus(i) {\n const list = document.getElementById('combo-list-' + i);\n\n if (list) { list.style.display = ''; }\n}\n\nfunction onComboBlur(i) {\n // mousedown+preventDefault() on options already stops most blurs from firing before a pick\n // registers; this is just a safety net for focus leaving for any other reason (e.g. Tab).\n setTimeout(() => {\n const list = document.getElementById('combo-list-' + i);\n\n if (list) { list.style.display = 'none'; }\n }, 150);\n}\n\nfunction onComboFilter(i, text) {\n const list = document.getElementById('combo-list-' + i);\n\n if (!list) { return; }\n\n const needle = text.toLowerCase();\n\n Array.from(list.children).forEach((opt) => {\n const hay = (opt.dataset.id + ' ' + opt.dataset.text).toLowerCase();\n\n opt.style.display = hay.indexOf(needle) === -1 ? 'none' : '';\n });\n}\n\nfunction onSingleComboInput(i, text) {\n onComboFilter(i, text);\n updateValueField(i, text);\n}\n\nfunction onComboOptionPick(optEl) {\n const list = optEl.closest('.ve-combo-list');\n const i = Number(list.id.replace('combo-list-', ''));\n const id = optEl.dataset.id;\n const text = optEl.dataset.text || id;\n const combo = optEl.closest('.ve-combo');\n const chipsEl = combo.querySelector('.ve-combo-chips');\n\n if (chipsEl) {\n const already = Array.from(chipsEl.children).some((c) => c.dataset.id === id);\n\n if (!already) {\n chipsEl.insertAdjacentHTML('beforeend', chipHtml(id, text));\n }\n\n const inputEl = combo.querySelector('.ve-combo-input');\n\n inputEl.value = '';\n onComboFilter(i, '');\n commitMultiValue(i, chipsEl);\n } else {\n const inputEl = combo.querySelector('.ve-combo-input');\n\n inputEl.value = id;\n updateValueField(i, id);\n list.style.display = 'none';\n }\n}\n\nfunction onChipRemove(btn) {\n const chip = btn.closest('.ve-chip');\n const chipsEl = chip.parentElement;\n const i = Number(chipsEl.dataset.row);\n\n chip.remove();\n commitMultiValue(i, chipsEl);\n}\n\nfunction commitMultiValue(i, chipsEl) {\n const ids = Array.from(chipsEl.children).map((c) => c.dataset.id);\n\n updateValueField(i, ids.join(','));\n}\n\n// Non-empty additional_options (array) means column-defined objects; empty/absent means a flat\n// list of strings. A bare string entry is shorthand for { name: <string>, type: 'textarea' }.\nfunction listConfigFor(tag) {\n const opts = tag && tag.additional_options;\n\n return Array.isArray(opts) && opts.length\n ? opts.map((c) => (typeof c === 'string' ? { name: c, type: 'textarea' } : c))\n : null;\n}\n\nfunction parseListItems(row) {\n try {\n const parsed = row.value ? JSON.parse(row.value) : [];\n\n return Array.isArray(parsed) ? parsed : null;\n } catch (e) {\n return null;\n }\n}\n\nfunction listValueWidget(tag, row, i) {\n const items = parseListItems(row);\n\n if (items === null) {\n return '<textarea oninput="updateValueField(' + i + ', this.value)">' + escapeHtml(row.value) + '</textarea>' +\n '<div class="hint" style="color:var(--red)">Not a valid JSON array — edit as raw text above, or fix it via "View/edit raw JSON".</div>';\n }\n\n const config = listConfigFor(tag);\n const itemsHtml = items.map((item, itemIndex) => listItemHtml(config, item, i, itemIndex)).join('');\n\n return '<div class="ve-list-items" id="list-items-' + i + '">' + (itemsHtml || '<div class="hint">No items yet.</div>') + '</div>' +\n '<button type="button" class="btn btn-sm" style="margin-top:6px" onclick="addListItem(' + i + ')">+ Add item</button>';\n}\n\nfunction listItemHtml(config, item, i, itemIndex) {\n const removeBtn = '<button type="button" class="btn btn-sm" onclick="removeListItem(' + i + ',' + itemIndex + ')">✕</button>';\n\n if (!config) {\n const val = typeof item === 'string' ? item : String(item != null ? item : '');\n\n return '<div class="ve-list-item">' +\n '<input type="text" value="' + escapeHtml(val) + '" oninput="updateListItemField(' + i + ',' + itemIndex + ',null,this.value)" />' +\n removeBtn +\n '</div>';\n }\n\n const fieldsHtml = config.map((col) => {\n const colName = typeof col === 'string' ? col : col.name;\n const colNameJs = escapeJsAttr(colName);\n const colType = typeof col === 'string' ? 'textarea' : (col.type || 'textarea');\n const rawVal = typeof item === 'object' && item !== null ? item[colName] : '';\n const val = rawVal != null ? String(rawVal) : '';\n\n if (colType === 'color') {\n const safe = /^#[0-9a-fA-F]{3,8}$/.test(val) ? val : '#000000';\n\n return '<div class="ve-list-field"><label>' + escapeHtml(colName) + '</label><input type="color" value="' + safe + '" oninput="updateListItemField(' + i + ',' + itemIndex + ',\\'' + colNameJs + '\\',this.value)" /></div>';\n }\n\n if (colType === 'select' || colType === 'multi-select') {\n const isMulti = colType === 'multi-select';\n const opts = typeof col === 'object' && Array.isArray(col.options) ? col.options : [];\n const selected = isMulti ? val.split(',').filter(Boolean) : [val];\n const optionsHtml = opts.map((o) => '<option value="' + escapeHtml(o) + '"' + (selected.indexOf(o) !== -1 ? ' selected' : '') + '>' + escapeHtml(o) + '</option>').join('');\n\n return '<div class="ve-list-field"><label>' + escapeHtml(colName) + '</label><select ' + (isMulti ? 'multiple' : '') + ' onchange="onListItemSelectChange(' + i + ',' + itemIndex + ',\\'' + colNameJs + '\\',this,' + isMulti + ')">' + optionsHtml + '</select></div>';\n }\n\n if (colType === 'file') {\n // Kept as a plain path input — the full browse/upload combo is reserved for top-level\n // "file" variables; adding it per list-column too isn't worth the complexity here.\n return '<div class="ve-list-field"><label>' + escapeHtml(colName) + '</label><input type="text" value="' + escapeHtml(val) + '" oninput="updateListItemField(' + i + ',' + itemIndex + ',\\'' + colNameJs + '\\',this.value)" /></div>';\n }\n\n return '<div class="ve-list-field"><label>' + escapeHtml(colName) + '</label><textarea oninput="updateListItemField(' + i + ',' + itemIndex + ',\\'' + colNameJs + '\\',this.value)">' + escapeHtml(val) + '</textarea></div>';\n }).join('');\n\n return '<div class="ve-list-item ve-list-item-cols"><div class="ve-list-item-fields">' + fieldsHtml + '</div><div class="ve-list-item-actions">' + removeBtn + '</div></div>';\n}\n\nfunction onListItemSelectChange(i, itemIndex, fieldName, selectEl, isMulti) {\n const value = isMulti\n ? Array.from(selectEl.selectedOptions).map((o) => o.value).join(',')\n : selectEl.value;\n\n updateListItemField(i, itemIndex, fieldName, value);\n}\n\nfunction updateListItemField(i, itemIndex, fieldName, value) {\n const items = parseListItems(valueRows[i]) || [];\n\n if (fieldName === null) {\n items[itemIndex] = value;\n } else {\n if (typeof items[itemIndex] !== 'object' || items[itemIndex] === null || Array.isArray(items[itemIndex])) {\n items[itemIndex] = {};\n }\n\n items[itemIndex][fieldName] = value;\n }\n\n valueRows[i].value = JSON.stringify(items);\n setDirty(true);\n}\n\nfunction addListItem(i) {\n const items = parseListItems(valueRows[i]) || [];\n const config = listConfigFor(schemaTagFor(valueRows[i].name));\n\n if (config) {\n const newItem = {};\n\n config.forEach((col) => { newItem[typeof col === 'string' ? col : col.name] = ''; });\n items.push(newItem);\n } else {\n items.push('');\n }\n\n valueRows[i].value = JSON.stringify(items);\n setDirty(true);\n render();\n}\n\nfunction removeListItem(i, itemIndex) {\n const items = parseListItems(valueRows[i]) || [];\n\n items.splice(itemIndex, 1);\n valueRows[i].value = JSON.stringify(items);\n setDirty(true);\n render();\n}\n\nfunction valueEditorCell(row, i) {\n const tag = schemaTagFor(row.name);\n const type = tag ? tag.tag_type : null;\n\n if (type === 'boolean') {\n // MI's own page-variable UI writes literal 'true'/'false' strings, not 'Y'/'N' — the\n // extra accepted values below are just legacy/lenient reads, not something we write.\n const checked = ['true','Y','1'].includes(row.value);\n\n return '<input type="checkbox" ' + (checked ? 'checked' : '') + ' onchange="updateValueField(' + i + ', this.checked ? \\'true\\' : \\'false\\')" />';\n }\n\n if (type === 'color') {\n const safe = /^#[0-9a-fA-F]{3,8}$/.test(row.value) ? row.value : '#000000';\n\n return '<input type="color" value="' + safe + '" oninput="updateValueField(' + i + ', this.value)" />';\n }\n\n if ((type === 'select' || type === 'multiselect') && tag) {\n const source = tag.tag_source || 'static';\n\n if (source === 'static') {\n return selectValueWidget(tag, row, i);\n }\n\n return '<input type="text" value="' + escapeHtml(row.value) + '" oninput="updateValueField(' + i + ', this.value)" placeholder="tag_source: ' + escapeHtml(source) + ' — no local option list" />';\n }\n\n if (type === 'file') {\n // No public MI API exists for listing/uploading a page's individual file assets (only\n // whole-bundle zip download/upload, which doesn't fit a single value) — manual path only.\n // MI's own upload control restricts these to images: jpg/jpeg/png/gif/svg.\n return '<input type="text" value="' + escapeHtml(row.value) + '" oninput="updateValueField(' + i + ', this.value)" placeholder="Existing image filename (jpg/jpeg/png/gif/svg)" />';\n }\n\n if (type === 'list') {\n return listValueWidget(tag, row, i);\n }\n\n if (type === 'text' && tag) {\n return textValueCell(tag, row, i);\n }\n\n return '<input type="text" value="' + escapeHtml(row.value) + '" oninput="updateValueField(' + i + ', this.value)" />';\n}\n\n// Same convention as the old "Export variables…"/"Setup variables…" flow (now retired in\n// favor of this JSON mode): a list-type value is shown/accepted as a native JSON array\n// instead of a JSON-string-in-a-string, converting only at this display/parse boundary —\n// the PUT /@api/variables/values endpoint itself always sees plain strings.\nfunction toExportableValueRows(rows) {\n return rows.map((row) => {\n const tag = schemaTagFor(row.name);\n\n if (tag && tag.tag_type === 'list') {\n try {\n const parsed = JSON.parse(row.value);\n\n if (Array.isArray(parsed)) {\n return { name: row.name, value: parsed };\n }\n } catch (e) {\n // not valid JSON — keep the raw string below\n }\n }\n\n return { name: row.name, value: row.value };\n });\n}\n\nfunction fromExportableValueRows(entries) {\n return entries.map(({ name, value }) => ({\n name,\n value: typeof value === 'string' ? value : JSON.stringify(value),\n }));\n}\n\n// Cheap, best-effort lint pass over the JSON textarea's current text — no textarea-internal\n// highlighting (native hover tooltips can't reach through an editable textarea sitting on top\n// of an overlay), just a small badge per flagged entry with a native title attribute explanation.\nfunction computeJsonIssues(text) {\n let entries;\n\n try {\n entries = JSON.parse(text);\n } catch (e) {\n return [];\n }\n\n if (!Array.isArray(entries)) { return []; }\n\n const baseline = new Map(\n toExportableValueRows(valuesState && valuesState.combined ? valuesState.combined : [])\n .map((e) => [e.name, JSON.stringify(e.value)]),\n );\n\n const issues = [];\n\n entries.forEach((entry) => {\n if (!entry || typeof entry.name !== 'string') { return; }\n\n const messages = [];\n const tag = schemaTagFor(entry.name);\n\n if (!tag) {\n messages.push('Not found in the current schema (__template_variables.json) — MI has no matching variable.');\n }\n\n if (tag && (tag.tag_type === 'select' || tag.tag_type === 'multiselect') && (tag.tag_source || 'static') === 'static') {\n const options = normalizeStaticOptions(tag);\n\n if (options.length && typeof entry.value === 'string') {\n const tokens = tag.tag_type === 'multiselect'\n ? entry.value.split(',').map((v) => v.trim()).filter(Boolean)\n : [entry.value].filter(Boolean);\n const unmatched = tokens.filter((t) => !options.some((o) => o.id === t || o.text === t));\n\n if (unmatched.length) {\n messages.push('Value "' + unmatched.join(', ') + '" doesn\\'t match any option in this variable\\'s static list.');\n }\n }\n }\n\n const canonicalValue = JSON.stringify(entry.value);\n\n if (baseline.has(entry.name)) {\n if (baseline.get(entry.name) !== canonicalValue) {\n messages.push('Changed since this tab was last loaded/refreshed.');\n }\n } else if (tag) {\n messages.push('New — this variable had no value when this tab was last loaded/refreshed.');\n }\n\n if (messages.length) {\n issues.push({ name: entry.name, message: messages.join(' ') });\n }\n });\n\n return issues;\n}\n\nfunction renderJsonIssuesBar(issues) {\n if (!issues.length) { return ''; }\n\n return '<div class="ve-json-issues">' + issues.map((issue) =>\n '<span class="ve-json-issue-badge" title="' + escapeHtml(issue.message) + '">⚠ ' + escapeHtml(issue.name) + '</span>',\n ).join('') + '</div>';\n}\n\nfunction refreshValuesJsonIssues() {\n const editorEl = document.getElementById('values-raw-editor');\n const issuesEl = document.getElementById('values-raw-issues');\n\n if (!editorEl || !issuesEl) { return; }\n\n issuesEl.innerHTML = renderJsonIssuesBar(computeJsonIssues(editorEl.value));\n}\n\nlet valuesRawIssuesTimer = null;\n\nfunction onValuesRawInput() {\n setDirty(true);\n\n if (valuesRawIssuesTimer) { clearTimeout(valuesRawIssuesTimer); }\n\n valuesRawIssuesTimer = setTimeout(refreshValuesJsonIssues, 300);\n}\n\nfunction downloadValuesJson() {\n const editorEl = document.getElementById('values-raw-editor');\n const text = editorEl ? editorEl.value : JSON.stringify(toExportableValueRows(valueRows), null, 2);\n const blob = new Blob([text], { type: 'application/json' });\n const url = URL.createObjectURL(blob);\n const a = document.createElement('a');\n\n a.href = url;\n a.download = 'page-variables.json';\n document.body.appendChild(a);\n a.click();\n a.remove();\n URL.revokeObjectURL(url);\n}\n\nfunction triggerImportValuesJson() {\n document.getElementById('import-values-file').click();\n}\n\nfunction onImportValuesFile(inputEl) {\n const file = inputEl.files[0];\n\n if (!file) { return; }\n\n const reader = new FileReader();\n\n reader.onload = () => {\n const editorEl = document.getElementById('values-raw-editor');\n\n if (editorEl) {\n editorEl.value = String(reader.result || '');\n setDirty(true);\n refreshValuesJsonIssues();\n }\n };\n\n reader.readAsText(file);\n inputEl.value = '';\n}\n\nfunction renderValuesTab() {\n if (!valuesState) { return renderSkeleton(); }\n\n if (valuesRawMode) {\n return \`\n <div class="raw-toggle">\n <button class="btn btn-sm" onclick="toggleValuesRawMode()">Switch to table view</button>\n <button class="btn btn-sm" onclick="downloadValuesJson()">Save to JSON file…</button>\n <button class="btn btn-sm" onclick="triggerImportValuesJson()">Import from JSON file…</button>\n <input type="file" id="import-values-file" accept="application/json,.json" style="display:none" onchange="onImportValuesFile(this)" />\n </div>\n <textarea class="raw-editor" id="values-raw-editor" spellcheck="false" oninput="onValuesRawInput()">\${escapeHtml(JSON.stringify(toExportableValueRows(valueRows), null, 2))}</textarea>\n <div id="values-raw-issues"></div>\n <div class="hint">Editing the raw JSON directly. Must be an array of { name, value } entries — value may be a native JSON array for list-type variables (converted to MI's plain-string form on save).</div>\n \`;\n }\n\n const rows = valueRows.map((row, i) => {\n const tag = schemaTagFor(row.name);\n // Source only means anything for select/multiselect (it picks where the options come\n // from) — leave a dash for every other type instead of repeating "static" everywhere.\n const showsSource = tag && (tag.tag_type === 'select' || tag.tag_type === 'multiselect');\n const sourceCell = showsSource ? '<span class="badge">' + escapeHtml(tag.tag_source || 'static') + '</span>' : '<span class="hint">—</span>';\n\n return \`\n <tr>\n <td class="col-name">\${escapeHtml(row.name)}\${descriptionHint(tag)}</td>\n <td class="col-type"><span class="badge">\${tag ? escapeHtml(tag.tag_type || 'text') : 'unknown'}</span></td>\n <td class="col-source">\${sourceCell}</td>\n <td>\${valueEditorCell(row, i)}</td>\n <td class="col-actions"><button class="btn btn-sm" onclick="removeValueRow(\${i})">✕</button></td>\n </tr>\n \`;\n }).join('');\n\n return \`\n <div class="raw-toggle"><button class="btn btn-sm" onclick="toggleValuesRawMode()">View/edit raw JSON</button></div>\n <table>\n <thead><tr><th class="col-name">Name</th><th class="col-type">Type</th><th class="col-source">Source</th><th>Value</th><th class="col-actions"></th></tr></thead>\n <tbody>\${rows || '<tr><td colspan="5" class="empty-state">No variables yet.</td></tr>'}</tbody>\n </table>\n <button class="btn btn-sm" style="margin-top:10px" onclick="addValueRow()">+ Add variable</button>\n \`;\n}\n\nfunction toggleValuesRawMode() {\n if (valuesRawMode) {\n // Leaving raw mode: try to parse back into rows; keep raw mode on failure. The value field\n // may be a native JSON array (list-type) as well as a plain string — see toExportableValueRows().\n const editorEl = document.getElementById('values-raw-editor');\n const editorText = editorEl ? editorEl.value : JSON.stringify(toExportableValueRows(valueRows));\n\n try {\n const parsed = JSON.parse(editorText);\n\n if (\n !Array.isArray(parsed) ||\n parsed.some((e) => typeof e !== 'object' || e === null || typeof e.name !== 'string' || e.value === undefined || e.value === null)\n ) {\n throw new Error('Must be an array of { name, value } entries with non-empty names and a defined value.');\n }\n\n valueRows = fromExportableValueRows(parsed);\n } catch (e) {\n // showBanner() re-renders, which would otherwise regenerate this textarea from the last\n // known-good valueRows — put the user's (still-broken) text back afterward so their\n // in-progress edit isn't silently discarded.\n showBanner('error', 'Cannot switch to table view: ' + escapeHtml(e.message));\n\n const freshEditorEl = document.getElementById('values-raw-editor');\n\n if (freshEditorEl) { freshEditorEl.value = editorText; }\n\n return;\n }\n }\n\n valuesRawMode = !valuesRawMode;\n setUrlState(activeTab, valuesRawMode);\n render();\n}\n\nfunction updateValueField(i, value) {\n valueRows[i].value = value;\n setDirty(true);\n}\n\nfunction addValueRow() {\n showCustomValueModal('Variable name', '', (name) => {\n if (!name) { return; }\n\n valueRows.push({ name, value: '' });\n setDirty(true);\n render();\n });\n}\n\nfunction removeValueRow(i) {\n const name = (valueRows[i] && valueRows[i].name) || '(unnamed)';\n\n showConfirmModal('Delete variable "' + name + '"? This only takes effect once you Save.', () => {\n valueRows.splice(i, 1);\n setDirty(true);\n render();\n });\n}\n\nasync function saveValues() {\n if (!valuesState) {\n showBanner('error', 'Cannot save values until live variables have loaded successfully.');\n\n return;\n }\n\n let tags = valueRows;\n\n if (valuesRawMode) {\n try {\n // The textarea may contain native JSON arrays for list-type values (see\n // toExportableValueRows()) — convert back to MI's plain-string form before sending.\n tags = fromExportableValueRows(JSON.parse(document.getElementById('values-raw-editor').value));\n } catch (e) {\n showBanner('error', 'Raw JSON is not valid: ' + escapeHtml(e.message));\n\n return;\n }\n }\n\n const saveEditSeq = editSeq;\n const r = await fetch('/@api/variables/values', {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ tags }),\n });\n const data = await r.json();\n\n if (!r.ok) {\n showBanner('error', escapeHtml(data.error || 'Save failed.'));\n\n return;\n }\n\n const warnings = data.warnings || [];\n const hasNewerEdits = editSeq !== saveEditSeq;\n const message = warnings.length\n ? 'Saved, with warning(s):<ul>' + warnings.map((w) => '<li>' + escapeHtml(w.message) + '</li>').join('') + '</ul>'\n : 'Values saved.';\n\n if (hasNewerEdits) {\n showBanner('warning', message + '<div>Newer edits remain unsaved.</div>', true);\n\n return;\n }\n\n setDirty(false);\n showBanner(warnings.length ? 'warning' : 'success', message);\n\n await loadTabData('values');\n render();\n}\n\nfunction save() {\n (activeTab === 'schema' ? saveSchema() : saveValues());\n}\n\n// ── Render ───────────────────────────────────────────────────────────────────\nfunction render() {\n const bannerHtml = banner ? '<div class="banner banner-' + banner.type + '">' + banner.html + '</div>' : '';\n\n contentEl.innerHTML = bannerHtml + (activeTab === 'schema' ? renderSchemaTab() : renderValuesTab());\n\n if (activeTab === 'values' && valuesRawMode) {\n refreshValuesJsonIssues();\n }\n\n const loadingEl = document.getElementById('loading-indicator');\n\n if (loadingEl) {\n // Only surface the indicator when there's cached content already on screen underneath it —\n // when there isn't (state is still null), renderSkeleton() above is the loading signal.\n const stateForTab = activeTab === 'schema' ? schemaState : valuesState;\n const isLoading = activeTab === 'schema' ? schemaLoading : valuesLoading;\n\n loadingEl.style.display = isLoading && stateForTab ? '' : 'none';\n }\n}\n\nwindow.switchTab = switchTab;\nwindow.refresh = refresh;\nwindow.save = save;\nwindow.toggleRawMode = toggleRawMode;\nwindow.updateSchemaField = updateSchemaField;\nwindow.onCustomSelectChange = onCustomSelectChange;\nwindow.updateSchemaAdditionalOptions = updateSchemaAdditionalOptions;\nwindow.updateEditorFlag = updateEditorFlag;\nwindow.showAdditionalOptionsHelp = showAdditionalOptionsHelp;\nwindow.toggleSchemaAdvanced = toggleSchemaAdvanced;\nwindow.addSchemaRow = addSchemaRow;\nwindow.removeSchemaRow = removeSchemaRow;\nwindow.updateValueField = updateValueField;\nwindow.addValueRow = addValueRow;\nwindow.removeValueRow = removeValueRow;\nwindow.toggleValuesRawMode = toggleValuesRawMode;\nwindow.onValuesRawInput = onValuesRawInput;\nwindow.downloadValuesJson = downloadValuesJson;\nwindow.triggerImportValuesJson = triggerImportValuesJson;\nwindow.onImportValuesFile = onImportValuesFile;\nwindow.onComboFocus = onComboFocus;\nwindow.onComboBlur = onComboBlur;\nwindow.onComboFilter = onComboFilter;\nwindow.onSingleComboInput = onSingleComboInput;\nwindow.onComboOptionPick = onComboOptionPick;\nwindow.onChipRemove = onChipRemove;\nwindow.addListItem = addListItem;\nwindow.removeListItem = removeListItem;\nwindow.updateListItemField = updateListItemField;\nwindow.onListItemSelectChange = onListItemSelectChange;\nwindow.setDirty = setDirty;\n\ndocument.getElementById('tab-schema').classList.toggle('active', activeTab === 'schema');\ndocument.getElementById('tab-values').classList.toggle('active', activeTab === 'values');\nsetUrlState(activeTab, valuesRawMode);\n\n// Skeleton first (nothing's cached yet on a fresh page load), then swap in the real content.\nrender();\nloadTabData(activeTab).then(render);\n})();\n<\/script>\n</body>\n</html>`)})}const Ft=["top-left","top-right","bottom-left","bottom-right"];function Lt(e,t){const n=e.mi?.mode??"standalone",a=e.app?.type??"template",s=e.mi?.apiVersion??7,o=e.mi?.url??process.env.MI_BACKEND_URL;if(!t)throw new Error("[pp-dev] app.name is required (or set package.json#name)");if(e.mi?.include&&"standalone"!==n)throw console.warn('[pp-dev] mi.include is only valid when mi.mode is "standalone"'),new Error('[pp-dev] mi.include requires mi.mode to be "standalone"');if(!o){if("embedding"===n||"template"===a)throw new Error('[pp-dev] mi.url is required when mi.mode is "embedding" or app.type is "template"');"standalone"===n&&"page"===a&&console.warn("[pp-dev] mi.url is not set — dev server will run without a backend proxy")}if("template"===a&&!e.app?.id)throw new Error('[pp-dev] app.id is required when app.type is "template"');if("page"===a&&o&&("standalone"===n||s>=7)&&!e.app?.id)throw new Error('[pp-dev] app.id is required when backend page data is loaded for app.type "page"');if(void 0!==e.devPanel?.position&&!Ft.includes(e.devPanel.position))throw new Error(`[pp-dev] devPanel.position must be one of: ${Ft.join(", ")}`)}function Mt(e,t){const{mi:n={},app:a={},proxy:s={},build:o={},sync:i={},inspector:r={},devPanel:l={}}=e,c=n.mode??"standalone",d=a.type??"template",p=n.apiVersion??7,u="standalone"===c;let h=!1;"top-bar"===n.include?h=!0:"shared-components"===n.include&&(h={addSharedComponentsScripts:!0,addRootElement:!1});const m="page"===d,g=a.name??t,f="VERSION-v{packageversion}-{currentDate}.json";let v;const b=o.zip;let y;v=!1!==b&&(!0===b||void 0===b?{outFileName:Ae(g),outDir:"dist-zip"}:{outFileName:Ie(b.fileName??Ae(g),"build.zip.fileName"),outDir:b.outDir??"dist-zip",...b.inDir?{inDir:b.inDir}:{}});const w=o.versionFile;let x;y=!1!==w&&(!0===w||void 0===w?{versionFileTemplate:f,enabled:!0}:{versionFileTemplate:Ie(w.fileNameTemplate??f,"build.versionFile.fileNameTemplate"),enabled:w.enabled??!0});const k=o.imageOptimisations;return x=!1!==k&&(!0===k||void 0===k?{}:k),{templateName:g,backendBaseURL:n.url??process.env.MI_BACKEND_URL,appId:a.id,templateLess:m,miHudLess:u,integrateMiTopBar:h,enableProxyCache:s.cache??!0,proxyCacheTTL:s.cacheTtl??6e5,disableSSLValidation:s.tls?.allowSelfSigned??!1,imageOptimizer:x,outDir:o.outDir??"dist",distZip:v,versionPlugin:y,syncBackupsDir:i.backupsDir??"backups",v7Features:p>=7,personalAccessToken:n.token??process.env.MI_ACCESS_TOKEN,inspectorEnabled:r.enabled??!0,inspectorMaxMemory:r.maxMemory??1073741824,inspectorCaptureLimit:r.captureLimit??10485760,devPanelPosition:l.position??"bottom-right",devPanelHidden:l.hidden??!1,devPanelAutoHide:l.autoHide??!1}}function Pt(e){const{templateName:t,templateLess:n,backendBaseURL:a,miHudLess:s,appId:o,enableProxyCache:i,proxyCacheTTL:r,disableSSLValidation:l,outDir:c,distZip:d,versionPlugin:p,syncBackupsDir:u,v7Features:h,personalAccessToken:m,inspectorEnabled:g,inspectorMaxMemory:f,inspectorCaptureLimit:v,devPanelPosition:b,devPanelHidden:y,devPanelAutoHide:w}=e||{};let x=!0;return{name:"vite-pp-dev",apply:"serve",config:e=>(e.clientInjectionPlugin={backendBaseURL:a,appId:o,templateLess:n,v7Features:h,devPanelPosition:b,devPanelHidden:y,devPanelAutoHide:w},h&&(e.base=`/pl/${t}`),e),transformIndexHtml:async(e,t)=>{const n={html:e,tags:[]};return x&&(x=!1,n.tags.push({tag:"script",injectTo:"body",children:`${Math.random()}`})),n},configureServer:b=>{let y=b.config.base;y.endsWith("/")||(y+="/");const w=y.substring(0,y.lastIndexOf("/"));if(b.middlewares.use(at(y,t)),!1!==g){const e=new ft(f);b.middlewares.use(yt(e,v));const t=mt();kt(t,e,v),b.middlewares.use((e,n,a)=>{e.url?.startsWith("/@api/")||e.url?.startsWith(wt)?t(e,n,a):a()})}if(a){b.config.logger.info(se.blue(`🌐 Backend URL: ${a}`)),b.config.logger.info(se.blue(`🆔 Custom App ID: ${o}`));const g=new URL(a).host,f={headers:{host:g,referer:a,origin:a.replace(/^(https?:\/\/)([^/]+)(\/.*)?$/i,"$1$2")},appId:o,templateLess:n,disableSSLValidation:l,v7Features:h,personalAccessToken:m??process.env.MI_ACCESS_TOKEN},v=new Te(a,f);if(i){let e=+r;(!e||Number.isNaN(e)||e<0)&&(e=6e5),b.middlewares.use(tt({devServer:b,ttl:e}))}const x=new RegExp(`^((${y})|/)$`);b.middlewares.use(dt(x,v,Object.assign({},e,{appId:o,appBase:y}))),b.middlewares.use(de({baseURL:a,proxyIgnore:["/@vite","/@metricinsights","/@",w],disableSSLValidation:l,miAPI:v,templateName:t}));const k=(e,t,n,a,s)=>{const o={error:n};a&&(o.details=a),s&&(o.code=s),e.status(t).json(o).end()},S=(e,t,n)=>{b.config.logger.error(`${n} token validation error:`,t);const a=ke(t);if(t.tokenErrorInfo){const n=t.tokenErrorInfo;k(e,n.status||500,n.userFriendlyMessage,n.message,n.code)}else{let t,s,o;switch(a.code){case"SESSION_EXPIRED":t=412,s="personal"===n?"Personal access token expired":"Session expired",o="personal"===n?"PAT_EXPIRED":"SESSION_EXPIRED";break;case"UNAUTHORIZED":t=401,s="Unauthorized",o="UNAUTHORIZED";break;case"FORBIDDEN":t=403,s="Access forbidden",o="FORBIDDEN";break;default:t=500,s="Internal server error",o=a.code}const i="personal"===n&&"PAT_EXPIRED"===o?"Your personal access token has expired. Please generate a new token from the portal.":"regular"===n&&"SESSION_EXPIRED"===o?"Your portal session has expired. Please refresh your token or re-authenticate.":a.userFriendlyMessage;k(e,t,s,i,o)}return null},E=mt();E.post("/@api/login",async(e,t,n)=>{const{token:a,tokenType:s}=e.body;if(a)if(b.config.logger.info(`Attempting to validate ${s} token...`),"personal"===s){if(!await v.get("/data/page/index/auth/info",{"Content-Type":"application/json",Accept:"application/json",Authorization:`Bearer ${a}`},!0).then(async e=>"number"==typeof e.data?.user?.user_id?(v.personalAccessToken=a,b.config.logger.info(`Personal access token validated successfully for user ${e.data.user.user_id}`),e):(k(t,400,"Token expired or invalid"),null)).catch(e=>S(t,e,"personal")))return;ie(t,"/",302)}else if("regular"===s){if(!await v.get("/api/user",{"Content-Type":"application/json",Accept:"application/json",Token:a},!0).then(e=>e.data?.users?.length?(v.personalAccessToken=void 0,b.config.logger.info(`Regular token validated successfully for ${e.data.users.length} user(s)`),t.setHeader("set-cookie",e.headers["set-cookie"]??""),e):(k(t,400,"Token expired or invalid"),null)).catch(e=>S(t,e,"regular")))return;ie(t,"/",302)}else k(t,400,"Invalid tokenType",'tokenType must be "personal" or "regular".',"INVALID_TOKEN_TYPE");else k(t,400,"Token is required")});const T=new Je(t,Object.assign({root:b.config.root,backupFolder:u,buildInputFolder:"object"==typeof d?d.inDir??c:c},"object"==typeof d?{distZipFolder:d.outDir,distZipFilename:d.outFileName}:void 0,"object"==typeof p?{versionFileTemplate:p.versionFileTemplate}:void 0));return new We(b,{distService:T,miAPI:v}),Ht(E,{distService:T,miAPI:v}),b.middlewares.use((e,t,n)=>{e.url?.startsWith("/@api/login")||e.url?.startsWith("/@api/variables/")||e.url?.startsWith(Ot)?E(e,t,n):n()}),()=>{b.middlewares.use(nt(e=>e.split("?")[0].endsWith("index.html"),(e,t)=>Buffer.from(oe(g,t.headers.host??"",v.buildPage(e,s)))))}}}}}export{z as A,q as B,We as C,Je as D,Ie as E,st as F,ot as G,Pt as H,wt as I,Te as M,K as P,ft as R,W as V,se as a,Q as b,ae as c,Ae as d,Re as e,ee as f,Z as g,X as h,G as i,kt as j,gt as k,yt as l,at as m,Mt as n,tt as o,dt as p,de as q,He as r,Ot as s,nt as t,oe as u,Lt as v,je as w,Ht as x,Y as y,Ne as z};
|
|
2
|
-
//# sourceMappingURL=plugin-FSAnUcfG.js.map
|