From deebd9aa6595ddc5c51ef1bc5317fee6043e13a2 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Tue, 27 Oct 2026 10:53:59 +0100 Subject: [PATCH] feat: SEO field elements and bundle --- assets/dist/seo.css | 1 + assets/dist/seo.js | 1 + assets/src/elements/image.ts | 121 +++ assets/src/elements/index.ts | 23 + assets/src/elements/labels.ts | 338 ++++++ assets/src/elements/measure.ts | 106 ++ assets/src/elements/robots.ts | 357 +++++++ assets/src/elements/serp.ts | 434 ++++++++ assets/src/elements/styles.css | 459 +++++++++ assets/src/elements/text.ts | 160 +++ package-lock.json | 1774 ++++++++++++++++++++++++++++++++ package.json | 19 + scripts/check-dist.sh | 26 + tsconfig.json | 17 + vite.config.ts | 35 + 15 files changed, 3871 insertions(+) create mode 100644 assets/dist/seo.css create mode 100644 assets/dist/seo.js create mode 100644 assets/src/elements/image.ts create mode 100644 assets/src/elements/index.ts create mode 100644 assets/src/elements/labels.ts create mode 100644 assets/src/elements/measure.ts create mode 100644 assets/src/elements/robots.ts create mode 100644 assets/src/elements/serp.ts create mode 100644 assets/src/elements/styles.css create mode 100644 assets/src/elements/text.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100755 scripts/check-dist.sh create mode 100644 tsconfig.json create mode 100644 vite.config.ts diff --git a/assets/dist/seo.css b/assets/dist/seo.css new file mode 100644 index 0000000..ffa3a5b --- /dev/null +++ b/assets/dist/seo.css @@ -0,0 +1 @@ +golem15-seo-text,golem15-seo-robots,golem15-seo-serp,golem15-seo-image{display:block;color:var(--c-text);font-size:14px}golem15-seo-text .seo-visually-hidden,golem15-seo-image .seo-visually-hidden{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}golem15-seo-text .seo-text__control{box-sizing:border-box;width:100%;height:44px;padding:0 14px;border:1px solid var(--c-border-strong);border-radius:10px;background:var(--c-surface);color:var(--c-text);font:inherit;font-size:14px;transition:border-color .15s,box-shadow .15s}golem15-seo-text .seo-text__control--area{height:auto;min-height:96px;padding:10px 14px;resize:vertical}golem15-seo-text .seo-text__control:focus{outline:none;border-color:var(--c-primary);box-shadow:0 0 0 3px var(--c-ring)}golem15-seo-text.seo-text--invalid .seo-text__control{border-color:var(--c-danger)}golem15-seo-text .seo-text__counter{display:flex;justify-content:space-between;gap:16px;flex-wrap:wrap;margin-top:8px;font-size:13px}golem15-seo-text .seo-text__status{display:inline-flex;align-items:center;gap:6px}golem15-seo-text .seo-text__icon{display:inline-flex}golem15-seo-text .seo-text__count{color:var(--c-muted);font-variant-numeric:tabular-nums}golem15-seo-text[data-status=empty] .seo-text__status{color:var(--c-muted)}golem15-seo-text[data-status=good] .seo-text__status{color:var(--c-ok-text, var(--c-text))}golem15-seo-robots .seo-robots__groups{display:flex;flex-wrap:wrap;gap:24px}golem15-seo-robots .seo-robots__group{display:flex;flex-direction:column;gap:6px}golem15-seo-robots .seo-robots__caption,golem15-seo-robots .seo-robots__summary,golem15-seo-robots .seo-robots__hint{color:var(--c-muted);font-size:13px}golem15-seo-robots .seo-robots__track,golem15-seo-serp .seo-serp__track{display:inline-flex;gap:4px;padding:4px;border:1px solid var(--c-border);border-radius:12px;background:var(--c-subtle)}golem15-seo-robots .seo-robots__segment,golem15-seo-serp .seo-serp__segment{height:34px;padding:0 16px;border:0;border-radius:9px;background:transparent;color:var(--c-muted);font:inherit;font-size:13px;cursor:pointer;transition:background-color .15s,color .15s}golem15-seo-robots .seo-robots__segment[aria-checked=true],golem15-seo-serp .seo-serp__segment[aria-checked=true]{font-size:14px;font-weight:600;background:var(--c-surface);color:var(--c-text);box-shadow:var(--shadow-tab, none)}golem15-seo-robots .seo-robots__segment--ok[aria-checked=true]{background:var(--c-ok-bg, var(--c-surface));color:var(--c-ok-text, var(--c-text))}golem15-seo-robots .seo-robots__segment--danger[aria-checked=true]{background:var(--c-danger-soft);color:var(--c-danger)}golem15-seo-robots .seo-robots__segment:focus-visible,golem15-seo-robots .seo-robots__toggle:focus-visible,golem15-seo-serp .seo-serp__segment:focus-visible,golem15-seo-serp .seo-serp__link:focus-visible,golem15-seo-image .seo-image__button:focus-visible{outline:3px solid var(--c-ring);outline-offset:2px}golem15-seo-robots .seo-robots__advanced-row{display:flex;align-items:center;gap:12px;margin-top:12px}golem15-seo-robots .seo-robots__toggle{display:inline-flex;align-items:center;gap:6px;padding:0;border:0;background:none;color:var(--c-text);font:inherit;font-size:14px;font-weight:600;cursor:pointer}golem15-seo-robots .seo-robots__toggle[aria-expanded=true] .seo-icon{transform:rotate(180deg)}golem15-seo-robots .seo-robots__panel{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:12px}golem15-seo-robots .seo-robots__panel[hidden]{display:none}golem15-seo-robots .seo-robots__field{display:flex;flex-direction:column;gap:6px}golem15-seo-robots .seo-robots__input{box-sizing:border-box;height:44px;padding:0 14px;border:1px solid var(--c-border-strong);border-radius:10px;background:var(--c-surface);color:var(--c-text);font:inherit}golem15-seo-robots .seo-robots__input:focus{outline:none;border-color:var(--c-primary);box-shadow:0 0 0 3px var(--c-ring)}golem15-seo-robots .seo-robots__error{color:var(--c-danger);font-size:13px}golem15-seo-robots .seo-robots__checks{grid-column:1 / -1;display:flex;flex-direction:column;gap:8px}golem15-seo-robots .seo-robots__check{display:inline-flex;align-items:center;gap:8px}golem15-seo-robots .seo-robots__check input{width:20px;height:20px;border-radius:5px;accent-color:var(--c-primary)}golem15-seo-robots.seo-robots--invalid .seo-robots__track{border-color:var(--c-danger)}golem15-seo-serp .seo-serp__skeleton{max-width:600px;height:132px;border-radius:12px;background:var(--c-skel)}golem15-seo-serp .seo-serp__header{display:flex;justify-content:space-between;align-items:center;gap:16px;flex-wrap:wrap;margin-bottom:12px}golem15-seo-serp .seo-serp__language,golem15-seo-serp .seo-serp__notes{color:var(--c-muted);font-size:13px}golem15-seo-serp .serp-card{--serp-bg: #ffffff;--serp-site: #202124;--serp-title: #1a0dab;--serp-text: #4d5156;box-sizing:border-box;max-width:600px;padding:16px;border:1px solid var(--c-border);border-radius:12px;background:var(--serp-bg);font-family:Arial,sans-serif;overflow-wrap:anywhere}golem15-seo-serp .serp-card--mobile{max-width:360px}golem15-seo-serp .serp-card__site{display:flex;align-items:center;gap:10px}golem15-seo-serp .serp-card__favicon{display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;border:1px solid var(--c-border);border-radius:50%;background:var(--c-subtle);color:var(--c-muted);font-size:14px;font-weight:600}golem15-seo-serp .serp-card__names{display:flex;flex-direction:column;color:var(--serp-site)}golem15-seo-serp .serp-card__sitename{font-size:14px}golem15-seo-serp .serp-card__crumbs{font-size:12px}golem15-seo-serp .serp-card__title{margin-top:6px;color:var(--serp-title);font-size:20px;line-height:1.3;white-space:nowrap}golem15-seo-serp .serp-card--mobile .serp-card__title{white-space:normal}golem15-seo-serp .serp-card__description{margin-top:4px;color:var(--serp-text);font-size:14px;line-height:1.58}golem15-seo-serp .serp-card__description--missing{font-style:italic}golem15-seo-serp .seo-serp__notes{display:flex;flex-direction:column;gap:8px;margin-top:8px}golem15-seo-serp .seo-serp__error{display:inline-flex;align-items:center;gap:6px;color:var(--c-danger)}golem15-seo-serp .seo-serp__checks,golem15-seo-serp .seo-serp__keyphrase{display:flex;flex-direction:column;gap:6px;margin-top:12px;font-size:13px}golem15-seo-serp .seo-serp__checking{color:var(--c-muted)}golem15-seo-serp .seo-serp__keyphrase-title{font-size:14px;font-weight:600}golem15-seo-serp .seo-serp__check{display:inline-flex;align-items:flex-start;gap:6px}golem15-seo-serp .seo-serp__check--ok{color:var(--c-ok-text, var(--c-text))}golem15-seo-serp .seo-serp__check .seo-icon{flex:none;margin-top:2px}golem15-seo-serp .seo-serp__link{color:var(--c-text);text-decoration:underline}golem15-seo-image .seo-image__preview{margin-bottom:8px}golem15-seo-image .seo-image__img{display:block;width:240px;height:126px;object-fit:cover;border:1px solid var(--c-border);border-radius:10px;background:var(--c-subtle)}golem15-seo-image .seo-image__actions{display:flex;gap:8px}golem15-seo-image .seo-image__button{display:inline-flex;align-items:center;height:36px;padding:0 14px;border:1px solid var(--c-border-strong);border-radius:10px;background:var(--c-surface);color:var(--c-text);font:inherit;font-size:14px;cursor:pointer}golem15-seo-image .seo-image__status{margin-top:6px;color:var(--c-muted);font-size:13px}golem15-seo-image .seo-image__status--error{color:var(--c-danger)}golem15-seo-image.seo-image--invalid .seo-image__img{border-color:var(--c-danger)}@media(max-width:600px){golem15-seo-robots .seo-robots__panel{grid-template-columns:1fr}golem15-seo-serp .serp-card,golem15-seo-serp .serp-card--mobile{max-width:none}}@media(prefers-reduced-motion:reduce){golem15-seo-text .seo-text__control,golem15-seo-robots .seo-robots__segment,golem15-seo-serp .seo-serp__segment{transition:none}} diff --git a/assets/dist/seo.js b/assets/dist/seo.js new file mode 100644 index 0000000..419c7bf --- /dev/null +++ b/assets/dist/seo.js @@ -0,0 +1 @@ +const K={count:":chars characters · :px / :max px",good:"Good length",emptyTitle:"Empty: the title template is used",emptyDescription:"Empty: the template or the excerpt is used",shortTitle:"Short: aim for 30–60 characters",shortDescription:"Short: aim for 120–160 characters",longTitle:"Too long: Google cuts titles after about 580 px",longDescription:"Too long: Google cuts descriptions after about 920 px on desktop and 680 px on mobile",indexing:"Indexing",links:"Links",inherit:"Inherit",siteDefault:"Site default",index:"Index",noindex:"Noindex",follow:"Follow",nofollow:"Nofollow",advanced:"Advanced directives",advancedSet:{one:":count set",other:":count set"},maxSnippet:"Maximum snippet length (characters)",maxSnippetHint:"Empty: no limit. 0: no snippet. -1: no limit.",maxImagePreview:"Image preview size",notSet:"Not set",none:"None",standard:"Standard",large:"Large",maxVideoPreview:"Maximum video preview (seconds)",noarchive:"Do not show a cached copy (noarchive)",nosnippet:"Do not show a snippet (nosnippet)",noimageindex:"Do not index images (noimageindex)",unavailableAfter:"Remove from results after",invalidNumber:"Enter a whole number, -1 or more.",desktop:"Desktop",mobile:"Mobile",device:"Preview device",language:"Language: :locale",fromTemplate:"Title from the template “:template”",fromContent:"Description from the content excerpt",noDescription:"No description: Google picks text from the page.",noUrl:"The address appears after the first save.",savedNote:"The address and the H1 come from the saved record. Save to refresh them.",checking:"Checking for duplicates…",dupTitle:{one:"Same title as :count other page: :list",other:"Same title as :count other pages: :list"},dupDescription:{one:"Same description as :count other page: :list",other:"Same description as :count other pages: :list"},andMore:"and :count more",keyphrase:"Focus keyphrase “:phrase”",kpTitle:"In the SEO title",kpTitleNo:"Not in the SEO title",kpH1:"In the H1",kpH1No:"Not in the H1",kpDesc:"In the meta description",kpDescNo:"Not in the meta description",kpUrl:"In the address",kpUrlNo:"Not in the address",kpLength:"Text length: :count words",kpLengthNo:"Text length: :count words, aim for at least 300",contextFailed:"The preview data could not be loaded.",imageUpload:"Upload image",imageReplace:"Replace image",imageRemove:"Remove",imageUploading:"Uploading…",imageFailed:"The image could not be uploaded.",imageAlt:"Social image preview"},Q={count:"Znaki: :chars · :px / :max px",good:"Dobra długość",emptyTitle:"Puste: używany jest szablon tytułu",emptyDescription:"Puste: używany jest szablon lub zajawka",shortTitle:"Krótki: celuj w 30–60 znaków",shortDescription:"Krótki: celuj w 120–160 znaków",longTitle:"Za długi: Google ucina tytuły po ok. 580 px",longDescription:"Za długi: Google ucina opisy po ok. 920 px na komputerze i 680 px na telefonie",indexing:"Indeksowanie",links:"Linki",inherit:"Dziedzicz",siteDefault:"Domyślne",index:"Indeksuj",noindex:"Nie indeksuj",follow:"Śledź",nofollow:"Nie śledź",advanced:"Dyrektywy zaawansowane",advancedSet:{one:"ustawiona :count",few:"ustawione :count",many:"ustawionych :count",other:"ustawione :count"},maxSnippet:"Maksymalna długość fragmentu (znaki)",maxSnippetHint:"Puste: bez limitu. 0: bez fragmentu. -1: bez limitu.",maxImagePreview:"Rozmiar podglądu obrazu",notSet:"Nie ustawiono",none:"Brak",standard:"Standardowy",large:"Duży",maxVideoPreview:"Maksymalny podgląd wideo (sekundy)",noarchive:"Nie pokazuj kopii (noarchive)",nosnippet:"Nie pokazuj fragmentu (nosnippet)",noimageindex:"Nie indeksuj obrazów (noimageindex)",unavailableAfter:"Usuń z wyników po dniu",invalidNumber:"Wpisz liczbę całkowitą, -1 lub większą.",desktop:"Komputer",mobile:"Telefon",device:"Urządzenie podglądu",language:"Język: :locale",fromTemplate:"Tytuł z szablonu „:template”",fromContent:"Opis z zajawki treści",noDescription:"Brak opisu: Google wybierze tekst ze strony.",noUrl:"Adres pojawi się po pierwszym zapisie.",savedNote:"Adres i H1 pochodzą z zapisanego rekordu. Zapisz, aby je odświeżyć.",checking:"Sprawdzanie duplikatów…",dupTitle:{one:"Ten sam tytuł co :count inna strona: :list",few:"Ten sam tytuł co :count inne strony: :list",many:"Ten sam tytuł co :count innych stron: :list",other:"Ten sam tytuł co :count innej strony: :list"},dupDescription:{one:"Ten sam opis co :count inna strona: :list",few:"Ten sam opis co :count inne strony: :list",many:"Ten sam opis co :count innych stron: :list",other:"Ten sam opis co :count innej strony: :list"},andMore:"i :count więcej",keyphrase:"Fraza kluczowa „:phrase”",kpTitle:"W tytule SEO",kpTitleNo:"Brak w tytule SEO",kpH1:"W nagłówku H1",kpH1No:"Brak w nagłówku H1",kpDesc:"W opisie meta",kpDescNo:"Brak w opisie meta",kpUrl:"W adresie",kpUrlNo:"Brak w adresie",kpLength:"Długość tekstu: :count słów",kpLengthNo:"Długość tekstu: :count słów, celuj w co najmniej 300",contextFailed:"Nie udało się wczytać danych podglądu.",imageUpload:"Wgraj obraz",imageReplace:"Zmień obraz",imageRemove:"Usuń",imageUploading:"Wgrywanie…",imageFailed:"Nie udało się wgrać obrazu.",imageAlt:"Podgląd obrazu w mediach społecznościowych"},ee={en:K,pl:Q};function k(i){return(i.getAttribute("ui-locale")||document.documentElement.lang||"en").toLowerCase().split(/[-_]/)[0]||"en"}function I(i){return ee[i]??K}function y(i,e={}){const t=Object.keys(e).sort((s,o)=>o.length-s.length);let n=i;for(const s of t)n=n.split(":"+s).join(String(e[s]));return n}function $(i,e,t,n={}){let s="other";try{s=new Intl.PluralRules(t).select(e)}catch{s=e===1?"one":"other"}const o=s==="one"&&i.one||s==="few"&&i.few||s==="many"&&i.many||i.other;return y(o,{...n,count:new Intl.NumberFormat(t).format(e)})}function L(i,e){try{return new Intl.NumberFormat(e).format(i)}catch{return String(i)}}const F="http://www.w3.org/2000/svg",te={info:["M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20z","M12 16v-4","M12 8h.01"],"triangle-alert":["m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3","M12 9v4","M12 17h.01"],"circle-check":["M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20z","m9 12 2 2 4-4"],"circle-alert":["M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20z","M12 8v4","M12 16h.01"],"chevron-down":["m6 9 6 6 6-6"]};function E(i,e=14){const t=document.createElementNS(F,"svg");t.setAttribute("width",String(e)),t.setAttribute("height",String(e)),t.setAttribute("viewBox","0 0 24 24"),t.setAttribute("fill","none"),t.setAttribute("stroke","currentColor"),t.setAttribute("stroke-width","2"),t.setAttribute("stroke-linecap","round"),t.setAttribute("stroke-linejoin","round"),t.setAttribute("aria-hidden","true"),t.setAttribute("class","seo-icon seo-icon--"+i);for(const n of te[i]??[]){const s=document.createElementNS(F,"path");s.setAttribute("d",n),t.appendChild(s)}return t}function r(i,e="",t){const n=document.createElement(i);return e&&(n.className=e),t!==void 0&&(n.textContent=t),n}function j(i,e,t){const n=i.getAttribute(e);if(n===null||n==="")return t;try{return JSON.parse(n)}catch{return t}}function A(i,e,t){i.dispatchEvent(new CustomEvent(e,{detail:t,bubbles:!0,composed:!0}))}let U=0;function N(i){return U+=1,`${i}-${U}`}const H="golem15-seo-image";function ne(i){return/^\/(?!\/)/.test(i)||/^https?:\/\//i.test(i)}class ie extends HTMLElement{static observedAttributes=["disabled","invalid","ui-locale"];current="";pending="";error="";onUploadResult=e=>{const t=e.detail;!t||t.id!==this.pending||(this.pending="",typeof t.url=="string"&&t.url!==""?(this.current=t.url,this.error="",A(this,"summer-change",{value:this.current})):this.error=t.error||this.labels().imageFailed,this.render())};get value(){return this.current}set value(e){this.current=typeof e=="string"?e:"",this.render()}connectedCallback(){this.addEventListener("summer-upload-result",this.onUploadResult),this.render()}disconnectedCallback(){this.removeEventListener("summer-upload-result",this.onUploadResult)}attributeChangedCallback(){this.isConnected&&this.render()}labels(){return I(k(this))}render(){const e=this.labels(),t=this.hasAttribute("disabled"),n=[];if(this.current&&ne(this.current)){const o=r("div","seo-image__preview"),a=r("img","seo-image__img");a.src=this.current,a.alt=e.imageAlt,a.width=240,a.height=126,o.append(a),n.push(o)}if(!t){const o=r("div","seo-image__actions"),a=N("seo-image-file"),l=r("input","seo-visually-hidden");l.type="file",l.accept="image/jpeg,image/png,image/gif,image/webp",l.id=a,l.addEventListener("change",()=>{const u=l.files?.[0];u&&(this.pending=N("seo-image-upload"),this.error="",this.render(),A(this,"summer-upload",{id:this.pending,file:u}))});const c=r("label","seo-image__button",this.current?e.imageReplace:e.imageUpload);if(c.htmlFor=a,o.append(l,c),this.current){const u=r("button","seo-image__button seo-image__button--secondary",e.imageRemove);u.type="button",u.addEventListener("click",()=>{this.current="",this.error="",A(this,"summer-change",{value:""}),this.render()}),o.append(u)}n.push(o)}const s=r("div","seo-image__status"+(this.error?" seo-image__status--error":""));s.setAttribute("role","status"),s.textContent=this.pending?e.imageUploading:this.error,n.push(s),this.classList.toggle("seo-image--invalid",this.hasAttribute("invalid")),this.replaceChildren(...n)}}const O="golem15-seo-robots";function P(){return{index:"",follow:"",noarchive:!1,nosnippet:!1,noimageindex:!1,maxSnippet:null,maxImagePreview:"",maxVideoPreview:null,unavailableAfter:""}}function se(i){const e=P(),t=i.trim();if(t==="")return e;const n=new Set;for(const s of t.split(",")){const o=s.trim().toLowerCase();if(o==="")return null;const a=o.indexOf(":"),l=(a<0?o:o.slice(0,a)).trim(),c=a<0?null:o.slice(a+1).trim(),u=l==="noindex"?"index":l==="nofollow"?"follow":l;if(n.has(u))return null;switch(n.add(u),l){case"index":case"noindex":if(c!==null)return null;e.index=l;break;case"follow":case"nofollow":if(c!==null)return null;e.follow=l;break;case"noarchive":case"nosnippet":case"noimageindex":if(c!==null)return null;e[l]=!0;break;case"max-snippet":case"max-video-preview":{if(c===null||!/^-?\d+$/.test(c))return null;const d=Number.parseInt(c,10);if(d<-1)return null;l==="max-snippet"?e.maxSnippet=d:e.maxVideoPreview=d;break}case"max-image-preview":if(c!=="none"&&c!=="standard"&&c!=="large")return null;e.maxImagePreview=c;break;case"unavailable_after":if(c===null||!/^\d{4}-\d{2}-\d{2}$/.test(c)||Number.isNaN(Date.parse(c+"T00:00:00Z")))return null;e.unavailableAfter=c;break;default:return null}}return e}function oe(i){const e=[];return i.index&&e.push(i.index),i.follow&&e.push(i.follow),i.noarchive&&e.push("noarchive"),i.nosnippet&&e.push("nosnippet"),i.noimageindex&&e.push("noimageindex"),i.maxSnippet!==null&&e.push("max-snippet:"+i.maxSnippet),i.maxImagePreview&&e.push("max-image-preview:"+i.maxImagePreview),i.maxVideoPreview!==null&&e.push("max-video-preview:"+i.maxVideoPreview),i.unavailableAfter&&e.push("unavailable_after:"+i.unavailableAfter),e.join(",")}function z(i){return[i.noarchive,i.nosnippet,i.noimageindex,i.maxSnippet!==null,i.maxImagePreview!=="",i.maxVideoPreview!==null,i.unavailableAfter!==""].filter(Boolean).length}class re extends HTMLElement{static observedAttributes=["options","disabled","invalid","ui-locale","locale"];current="";model=P();open=!1;get value(){return this.current}set value(e){const t=typeof e=="string"?e:e==null?"":String(e);t===this.current&&this.childElementCount>0||(this.current=t,this.model=se(t)??P(),this.open=this.open||z(this.model)>0,this.render())}connectedCallback(){this.open=z(this.model)>0,this.render()}attributeChangedCallback(){this.isConnected&&this.render()}labels(){return I(k(this))}commit(){this.current=oe(this.model),A(this,"summer-change",{value:this.current}),this.render()}render(){const e=this.labels(),t=this.hasAttribute("disabled"),s=j(this,"options",{}).scope==="type"?e.siteDefault:e.inherit;this.classList.toggle("seo-robots--invalid",this.hasAttribute("invalid"));const o=document.activeElement&&this.contains(document.activeElement)?document.activeElement.dataset.focusKey:void 0,a=r("div","seo-robots__groups");a.append(this.radioGroup(e.indexing,"index",[["",s,"inherit"],["index",e.index,"ok"],["noindex",e.noindex,"danger"]],t),this.radioGroup(e.links,"follow",[["",s,"inherit"],["follow",e.follow,"ok"],["nofollow",e.nofollow,"danger"]],t));const l=N("seo-robots-advanced"),c=r("button","seo-robots__toggle");c.type="button",c.setAttribute("aria-expanded",this.open?"true":"false"),c.setAttribute("aria-controls",l),c.dataset.focusKey="toggle",c.append(r("span","",e.advanced),E("chevron-down")),c.addEventListener("click",()=>{this.open=!this.open,this.render()});const u=r("div","seo-robots__advanced-row");u.append(c);const d=z(this.model);d>0&&u.append(r("span","seo-robots__summary",$(e.advancedSet,d,k(this))));const m=r("div","seo-robots__panel");m.id=l,m.hidden=!this.open,m.append(this.numberField(e.maxSnippet,e.maxSnippetHint,"maxSnippet",t,e),this.selectField(e,t),this.numberField(e.maxVideoPreview,"","maxVideoPreview",t,e),this.dateField(e,t));const f=r("div","seo-robots__checks");for(const b of["noarchive","nosnippet","noimageindex"]){const p=r("label","seo-robots__check"),h=r("input");h.type="checkbox",h.checked=this.model[b],h.disabled=t,h.dataset.focusKey=b,h.addEventListener("change",()=>{this.model[b]=h.checked,this.commit()}),p.append(h,r("span","",e[b])),f.append(p)}m.append(f),this.replaceChildren(a,u,m),o&&this.querySelector(`[data-focus-key="${o}"]`)?.focus()}radioGroup(e,t,n,s){const o=r("div","seo-robots__group"),a=N("seo-robots-caption"),l=r("span","seo-robots__caption",e);l.id=a;const c=r("div","seo-robots__track");c.setAttribute("role","radiogroup"),c.setAttribute("aria-labelledby",a);const u=this.model[t];return n.forEach(([d,m,f],b)=>{const p=r("button","seo-robots__segment seo-robots__segment--"+f,m);p.type="button",p.setAttribute("role","radio");const h=u===d;p.setAttribute("aria-checked",h?"true":"false"),p.tabIndex=h?0:-1,p.disabled=s,p.dataset.focusKey=t+"-"+b,p.addEventListener("click",()=>this.choose(t,d)),p.addEventListener("keydown",v=>{const x=v.key==="ArrowRight"||v.key==="ArrowDown"?1:v.key==="ArrowLeft"||v.key==="ArrowUp"?-1:0;if(x===0||s)return;v.preventDefault();const T=(b+x+n.length)%n.length;this.choose(t,n[T][0]),this.querySelector(`[data-focus-key="${t}-${T}"]`)?.focus()}),c.append(p)}),o.append(l,c),o}choose(e,t){this.hasAttribute("disabled")||(e==="index"?this.model.index=t:this.model.follow=t,this.commit())}numberField(e,t,n,s,o){const a=r("label","seo-robots__field");a.append(r("span","seo-robots__label",e));const l=r("input","seo-robots__input");l.type="number",l.min="-1",l.step="1",l.disabled=s,l.dataset.focusKey=n;const c=this.model[n];l.value=c===null?"":String(c);const u=r("span","seo-robots__error");return u.setAttribute("role","status"),l.addEventListener("change",()=>{const d=l.value.trim();if(d===""){this.model[n]=null,this.commit();return}if(!/^-?\d+$/.test(d)||Number.parseInt(d,10)<-1){u.textContent=o.invalidNumber;return}this.model[n]=Number.parseInt(d,10),this.commit()}),a.append(l),t&&a.append(r("span","seo-robots__hint",t)),a.append(u),a}selectField(e,t){const n=r("label","seo-robots__field");n.append(r("span","seo-robots__label",e.maxImagePreview));const s=r("select","seo-robots__input");s.disabled=t,s.dataset.focusKey="maxImagePreview";for(const[o,a]of[["",e.notSet],["none",e.none],["standard",e.standard],["large",e.large]]){const l=r("option","",a);l.value=o,l.selected=this.model.maxImagePreview===o,s.append(l)}return s.addEventListener("change",()=>{this.model.maxImagePreview=s.value,this.commit()}),n.append(s),n}dateField(e,t){const n=r("label","seo-robots__field");n.append(r("span","seo-robots__label",e.unavailableAfter));const s=r("input","seo-robots__input");return s.type="date",s.disabled=t,s.dataset.focusKey="unavailableAfter",s.value=this.model.unavailableAfter,s.addEventListener("change",()=>{this.model.unavailableAfter=/^\d{4}-\d{2}-\d{2}$/.test(s.value)?s.value:"",this.commit()}),n.append(s),n}}const V="20px Arial, sans-serif",X="14px Arial, sans-serif",Z=580,J=920,ae=680,le=30,ce=120;let S;function ue(){if(S!==void 0)return S;try{const e=document.createElement("canvas").getContext("2d");S=e&&typeof e.measureText=="function"?e:null}catch{S=null}return S}function de(i,e){let t=0;for(const n of i)/[ilIj.,:;'!|]/.test(n)?t+=.28:/[mwMW@]/.test(n)?t+=.85:/[A-Z]/.test(n)?t+=.67:n===" "?t+=.28:t+=.53;return t*e}function M(i,e){const t=ue();if(t){t.font=e;const s=t.measureText(i).width;if(Number.isFinite(s)&&(s>0||i===""))return s}const n=Number.parseFloat(e)||14;return de(i,n)}function G(i,e,t){if(M(i,e)<=t)return i;const n=Array.from(i);let s=0,o=n.length;for(;sn?c="long":a=s}function C(i,e){const t=B(i);if(t.length===0)return!1;const n=B(e);for(let s=0;s+t.length<=n.length;s++){let o=!0;for(let a=0;a{const n=e.detail?.data;this.inflight=null,n&&n.op==="context"?(this.context=n,this.contextFailed=!1,this.render(),this.scheduleDuplicates(!0)):n&&n.op==="duplicates"&&(this.duplicates.set(n.locale,n),this.checking=!1,this.render()),this.flush()};onLocale=e=>{const t=e.detail;typeof t=="string"&&t!==""&&t!==this.shownLocale&&(this.shownLocale=t,this.render(),this.scheduleDuplicates(!1))};connectedCallback(){this.connected=!0,this.addEventListener("summer-result",this.onResult),window.addEventListener("summer:ml-locale",this.onLocale),this.render(),this.request({op:"context"})}disconnectedCallback(){this.connected=!1,this.removeEventListener("summer-result",this.onResult),window.removeEventListener("summer:ml-locale",this.onLocale),clearTimeout(this.timer)}attributeChangedCallback(e,t,n){if(this.connected){if(e==="busy"&&n===null){this.flush();return}if(e==="state"&&n==="error"){this.inflight?.op==="context"&&(this.contextFailed=!0),this.checking=!1,this.inflight=null,this.render();return}if(e==="fill-values"){this.render(),this.scheduleDuplicates(!1);return}this.render()}}labels(){return I(k(this))}request(e){if(this.hasAttribute("busy")||this.inflight){this.queued=e;return}this.inflight=e,A(this,"summer-action",{payload:e})}flush(){if(this.hasAttribute("busy")||this.inflight||!this.queued)return;const e=this.queued;this.queued=null,this.request(e)}fill(){return j(this,"fill-values",{})}locale(){const e=this.context?.locales??[];return e.length===0?null:e.find(t=>t.locale===this.shownLocale)??e[0]}scheduleDuplicates(e){const t=this.locale();if(!t)return;const n=this.fill(),s=w(n,"seo_title",t.locale)||w(n,"title",t.locale),o=w(n,"seo_description",t.locale)||w(n,"description",t.locale),a=JSON.stringify([t.locale,s,o]);if(a===this.lastChecked)return;clearTimeout(this.timer);const l=()=>{this.lastChecked=a,this.checking=!0,this.render(),this.request({op:"duplicates",locale:t.locale,title:s,description:o})};e?l():this.timer=setTimeout(l,me)}render(){const e=this.labels(),t=k(this);if(!this.context&&!this.contextFailed){this.setAttribute("aria-busy","true"),this.replaceChildren(r("div","seo-serp__skeleton"));return}this.removeAttribute("aria-busy");const n=this.locale(),s=n?.locale??this.shownLocale,o=this.fill(),a=(w(o,"seo_title",s)||w(o,"title",s)).trim(),l=(w(o,"seo_description",s)||w(o,"description",s)).trim(),c=w(o,"seo_keyphrase",s).trim(),u=n?.siteName??"",d=a||ge(n?.titleTemplate??"",{title:n?.defaultTitle??"",site:u})||n?.defaultTitle||"",m=l||n?.defaultDescription||"",f=r("div","seo-serp__header"),b=r("div","seo-serp__track");b.setAttribute("role","radiogroup"),b.setAttribute("aria-label",e.device);for(const g of["desktop","mobile"]){const _=r("button","seo-serp__segment",e[g]);_.type="button",_.setAttribute("role","radio"),_.setAttribute("aria-checked",this.device===g?"true":"false"),_.addEventListener("click",()=>{this.device=g,this.render()}),b.append(_)}f.append(b),s&&f.append(r("span","seo-serp__language",y(e.language,{locale:s.toUpperCase()})));const p=r("div","serp-card"+(this.device==="mobile"?" serp-card--mobile":"")),h=r("div","serp-card__site");h.append(r("span","serp-card__favicon",Array.from(u||this.context?.host||"?")[0]?.toUpperCase()??"?"));const v=r("div","serp-card__names");if(v.append(r("span","serp-card__sitename",u||this.context?.host||"")),v.append(r("span","serp-card__crumbs",we(this.context?.host??"",n?.url??null))),h.append(v),p.append(h),p.append(r("div","serp-card__title",G(d,V,Z))),m){const g=this.device==="mobile"?ae:J;p.append(r("div","serp-card__description",G(m,X,g)))}else p.append(r("div","serp-card__description serp-card__description--missing",e.noDescription));const x=r("div","seo-serp__notes");if(this.contextFailed){const g=r("div","seo-serp__error");g.setAttribute("role","status"),g.append(E("circle-alert"),r("span","",e.contextFailed)),x.append(g)}!a&&n?.titleTemplate&&x.append(r("div","",y(e.fromTemplate,{template:n.titleTemplate}))),!l&&n?.defaultDescription&&n.descriptionSource==="content"&&x.append(r("div","",e.fromContent)),n&&n.url===null&&x.append(r("div","",e.noUrl)),(this.getAttribute("record-id")??"")!==""&&n&&x.append(r("div","",e.savedNote));const T=r("div","seo-serp__checks");if(T.setAttribute("role","status"),this.checking)T.append(r("div","seo-serp__checking",e.checking));else{const g=this.duplicates.get(s);if(g)for(const[_,Y]of[[g.title,e.dupTitle],[g.description,e.dupDescription]])_.length>0&&T.append(_e(_,Y,e,t))}const R=[f,p,x,T];c&&n&&R.push(ke(c,{title:d,description:m,h1:n.h1,url:n.url,bodyWords:n.bodyWords},e,t)),this.replaceChildren(...R)}}function we(i,e){const t=(e??"").split("?")[0].split("/").filter(Boolean).map(n=>{try{return decodeURIComponent(n)}catch{return n}});return[i,...t].filter(Boolean).join(" › ")}function _e(i,e,t,n){const s=r("div","seo-serp__check seo-serp__check--warn");s.append(E("triangle-alert"));const o=r("span"),a="\0list\0",l=$(e,i.length,n,{list:a}),[c,u]=l.split(a);return o.append(document.createTextNode(c??"")),i.slice(0,D).forEach((d,m)=>{if(m>0&&o.append(document.createTextNode(", ")),d.url.startsWith("/")&&!d.url.startsWith("//")){const f=r("a","seo-serp__link",d.label);f.href=d.url,f.target="_blank",f.rel="noopener",o.append(f)}else o.append(document.createTextNode(d.label))}),i.length>D&&o.append(document.createTextNode(" "+y(t.andMore,{count:i.length-D}))),o.append(document.createTextNode(u??"")),s.append(o),s}function ke(i,e,t,n){const s=r("div","seo-serp__keyphrase");s.append(r("div","seo-serp__keyphrase-title",y(t.keyphrase,{phrase:i})));const o=(a,l,c)=>{const u=r("div","seo-serp__check "+(a?"seo-serp__check--ok":"seo-serp__check--warn"));u.append(E(a?"circle-check":"triangle-alert"),r("span","",a?l:c)),s.append(u)};if(o(C(i,e.title),t.kpTitle,t.kpTitleNo),e.h1!==null&&o(C(i,e.h1),t.kpH1,t.kpH1No),o(C(i,e.description),t.kpDesc,t.kpDescNo),e.url!==null&&o(C(i,e.url.replace(/[-_/]+/g," ")),t.kpUrl,t.kpUrlNo),e.bodyWords!==null){const a=new Intl.NumberFormat(n).format(e.bodyWords);o(e.bodyWords>=fe,y(t.kpLength,{count:a}),y(t.kpLengthNo,{count:a}))}return s}const q="golem15-seo-text",ye={empty:"info",short:"triangle-alert",long:"triangle-alert",good:"circle-check"};class Te extends HTMLElement{static observedAttributes=["options","disabled","invalid","ui-locale","locale"];current="";control=null;statusIcon=null;statusText=null;countText=null;live=null;lastStatus=null;builtFor="";get value(){return this.current}set value(e){this.current=typeof e=="string"?e:e==null?"":String(e),this.control&&this.control.value!==this.current&&(this.control.value=this.current),this.update(!1)}connectedCallback(){this.build()}attributeChangedCallback(e){if(this.isConnected){if(e==="options"||e==="ui-locale"){this.build();return}this.sync()}}measure(){return j(this,"options",{}).measure==="description"?"description":"title"}labels(){return I(k(this))}build(){const e=this.measure()+"|"+k(this);if(this.builtFor===e&&this.control){this.sync();return}this.builtFor=e,this.replaceChildren();const t=N("seo-text-counter"),s=this.measure()==="description"?r("textarea","seo-text__control seo-text__control--area"):r("input","seo-text__control");s instanceof HTMLTextAreaElement?s.rows=3:s.type="text",s.value=this.current,s.setAttribute("aria-describedby",t);const o=this.getAttribute("field-name");o&&s.setAttribute("aria-label",o),s.addEventListener("input",()=>{this.hasAttribute("disabled")||(this.current=s.value.replace(/\s+$/u,""),this.update(!0),A(this,"summer-change",{value:this.current}))});const a=r("div","seo-text__counter");a.id=t;const l=r("span","seo-text__status");this.statusIcon=r("span","seo-text__icon"),this.statusText=r("span","seo-text__status-text"),l.append(this.statusIcon,this.statusText),this.countText=r("span","seo-text__count"),a.append(l,this.countText),this.live=r("span","seo-visually-hidden"),this.live.setAttribute("aria-live","polite"),this.live.setAttribute("role","status"),this.control=s,this.lastStatus=null,this.append(s,a,this.live),this.sync()}sync(){if(!this.control)return;const e=this.hasAttribute("disabled");this.control.readOnly=e,this.classList.toggle("seo-text--invalid",this.hasAttribute("invalid")),this.control.setAttribute("aria-invalid",this.hasAttribute("invalid")?"true":"false"),this.update(!1)}update(e){if(!this.statusText||!this.countText||!this.statusIcon)return;const t=this.labels(),n=k(this),s=this.measure(),o=he(this.current,s),a=Ae(t,o.status,s);this.statusIcon.replaceChildren(E(ye[o.status])),this.statusText.textContent=a,this.dataset.status=o.status,this.countText.textContent=y(t.count,{chars:L(o.chars,n),px:L(o.px,n),max:L(o.maxPx,n)}),e&&this.live&&o.status!==this.lastStatus&&(this.live.textContent=a),this.lastStatus=o.status}}function Ae(i,e,t){switch(e){case"empty":return t==="title"?i.emptyTitle:i.emptyDescription;case"short":return t==="title"?i.shortTitle:i.shortDescription;case"long":return t==="title"?i.longTitle:i.longDescription;default:return i.good}}customElements.get(q)||customElements.define(q,Te);customElements.get(O)||customElements.define(O,re);customElements.get(W)||customElements.define(W,xe);customElements.get(H)||customElements.define(H,ie);export{H as IMAGE_TAG,O as ROBOTS_TAG,W as SERP_TAG,ie as SeoImageElement,re as SeoRobotsElement,xe as SeoSerpElement,Te as SeoTextElement,q as TEXT_TAG}; diff --git a/assets/src/elements/image.ts b/assets/src/elements/image.ts new file mode 100644 index 0000000..579f380 --- /dev/null +++ b/assets/src/elements/image.ts @@ -0,0 +1,121 @@ +import { el, emit, labelsFor, uid, uiLocaleOf, type Labels } from './labels' + +export const IMAGE_TAG = 'golem15-seo-image' + +/** Whether a stored image URL may be shown: a path or an http(s) URL. */ +export function safeImageURL(url: string): boolean { + return /^\/(?!\/)/.test(url) || /^https?:\/\//i.test(url) +} + +/** + * E7 golem15-seo-image (D-100): the social image of the SEO tab, the URL + * rule form and the settings. It uploads its own file through the field + * type's upload route (summer-upload / summer-upload-result) and keeps the + * stored URL as its value; Remove clears it. + */ +export class SeoImageElement extends HTMLElement { + static observedAttributes = ['disabled', 'invalid', 'ui-locale'] + + private current = '' + private pending = '' + private error = '' + + private readonly onUploadResult = (ev: Event): void => { + const detail = (ev as CustomEvent).detail as { id?: string; url?: string; error?: string } | undefined + if (!detail || detail.id !== this.pending) { + return + } + this.pending = '' + if (typeof detail.url === 'string' && detail.url !== '') { + this.current = detail.url + this.error = '' + emit(this, 'summer-change', { value: this.current }) + } else { + this.error = detail.error || this.labels().imageFailed + } + this.render() + } + + get value(): string { + return this.current + } + + set value(v: unknown) { + this.current = typeof v === 'string' ? v : '' + this.render() + } + + connectedCallback(): void { + this.addEventListener('summer-upload-result', this.onUploadResult) + this.render() + } + + disconnectedCallback(): void { + this.removeEventListener('summer-upload-result', this.onUploadResult) + } + + attributeChangedCallback(): void { + if (this.isConnected) { + this.render() + } + } + + private labels(): Labels { + return labelsFor(uiLocaleOf(this)) + } + + private render(): void { + const labels = this.labels() + const disabled = this.hasAttribute('disabled') + const parts: HTMLElement[] = [] + if (this.current && safeImageURL(this.current)) { + const figure = el('div', 'seo-image__preview') + const img = el('img', 'seo-image__img') + img.src = this.current + img.alt = labels.imageAlt + img.width = 240 + img.height = 126 + figure.append(img) + parts.push(figure) + } + if (!disabled) { + const actions = el('div', 'seo-image__actions') + const inputId = uid('seo-image-file') + const input = el('input', 'seo-visually-hidden') + input.type = 'file' + input.accept = 'image/jpeg,image/png,image/gif,image/webp' + input.id = inputId + input.addEventListener('change', () => { + const file = input.files?.[0] + if (!file) { + return + } + this.pending = uid('seo-image-upload') + this.error = '' + this.render() + emit(this, 'summer-upload', { id: this.pending, file }) + }) + const choose = el('label', 'seo-image__button', this.current ? labels.imageReplace : labels.imageUpload) + choose.htmlFor = inputId + actions.append(input, choose) + if (this.current) { + const remove = el('button', 'seo-image__button seo-image__button--secondary', labels.imageRemove) + remove.type = 'button' + remove.addEventListener('click', () => { + this.current = '' + this.error = '' + emit(this, 'summer-change', { value: '' }) + this.render() + }) + actions.append(remove) + } + parts.push(actions) + } + const statusLine = el('div', 'seo-image__status' + (this.error ? ' seo-image__status--error' : '')) + statusLine.setAttribute('role', 'status') + statusLine.textContent = this.pending ? labels.imageUploading : this.error + parts.push(statusLine) + this.classList.toggle('seo-image--invalid', this.hasAttribute('invalid')) + this.replaceChildren(...parts) + } +} diff --git a/assets/src/elements/index.ts b/assets/src/elements/index.ts new file mode 100644 index 0000000..ecb73de --- /dev/null +++ b/assets/src/elements/index.ts @@ -0,0 +1,23 @@ +import './styles.css' +import { IMAGE_TAG, SeoImageElement } from './image' +import { ROBOTS_TAG, SeoRobotsElement } from './robots' +import { SERP_TAG, SeoSerpElement } from './serp' +import { SeoTextElement, TEXT_TAG } from './text' + +// The admin SPA loads this script once on every admin page (the SEO field +// types list it in Scripts); defining a tag twice would throw, so a second +// evaluation is a no-op. +if (!customElements.get(TEXT_TAG)) { + customElements.define(TEXT_TAG, SeoTextElement) +} +if (!customElements.get(ROBOTS_TAG)) { + customElements.define(ROBOTS_TAG, SeoRobotsElement) +} +if (!customElements.get(SERP_TAG)) { + customElements.define(SERP_TAG, SeoSerpElement) +} +if (!customElements.get(IMAGE_TAG)) { + customElements.define(IMAGE_TAG, SeoImageElement) +} + +export { IMAGE_TAG, ROBOTS_TAG, SERP_TAG, SeoImageElement, SeoRobotsElement, SeoSerpElement, SeoTextElement, TEXT_TAG } diff --git a/assets/src/elements/labels.ts b/assets/src/elements/labels.ts new file mode 100644 index 0000000..ef1b7f7 --- /dev/null +++ b/assets/src/elements/labels.ts @@ -0,0 +1,338 @@ +// Element copy of the SEO field elements (UI-SPEC copywriting E1, E2, E3, +// plus E7 for the social image), chosen by the ui-locale attribute. Plural +// texts are CLDR maps; placeholders use :name. + +/** A CLDR plural map: one/other in English, one/few/many/other in Polish. */ +export interface Plural { + one: string + few?: string + many?: string + other: string +} + +export interface Labels { + // E1 + count: string + good: string + emptyTitle: string + emptyDescription: string + shortTitle: string + shortDescription: string + longTitle: string + longDescription: string + // E2 + indexing: string + links: string + inherit: string + siteDefault: string + index: string + noindex: string + follow: string + nofollow: string + advanced: string + advancedSet: Plural + maxSnippet: string + maxSnippetHint: string + maxImagePreview: string + notSet: string + none: string + standard: string + large: string + maxVideoPreview: string + noarchive: string + nosnippet: string + noimageindex: string + unavailableAfter: string + invalidNumber: string + // E3 + desktop: string + mobile: string + device: string + language: string + fromTemplate: string + fromContent: string + noDescription: string + noUrl: string + savedNote: string + checking: string + dupTitle: Plural + dupDescription: Plural + andMore: string + keyphrase: string + kpTitle: string + kpTitleNo: string + kpH1: string + kpH1No: string + kpDesc: string + kpDescNo: string + kpUrl: string + kpUrlNo: string + kpLength: string + kpLengthNo: string + contextFailed: string + // E7 + imageUpload: string + imageReplace: string + imageRemove: string + imageUploading: string + imageFailed: string + imageAlt: string +} + +const en: Labels = { + count: ':chars characters · :px / :max px', + good: 'Good length', + emptyTitle: 'Empty: the title template is used', + emptyDescription: 'Empty: the template or the excerpt is used', + shortTitle: 'Short: aim for 30–60 characters', + shortDescription: 'Short: aim for 120–160 characters', + longTitle: 'Too long: Google cuts titles after about 580 px', + longDescription: 'Too long: Google cuts descriptions after about 920 px on desktop and 680 px on mobile', + indexing: 'Indexing', + links: 'Links', + inherit: 'Inherit', + siteDefault: 'Site default', + index: 'Index', + noindex: 'Noindex', + follow: 'Follow', + nofollow: 'Nofollow', + advanced: 'Advanced directives', + advancedSet: { one: ':count set', other: ':count set' }, + maxSnippet: 'Maximum snippet length (characters)', + maxSnippetHint: 'Empty: no limit. 0: no snippet. -1: no limit.', + maxImagePreview: 'Image preview size', + notSet: 'Not set', + none: 'None', + standard: 'Standard', + large: 'Large', + maxVideoPreview: 'Maximum video preview (seconds)', + noarchive: 'Do not show a cached copy (noarchive)', + nosnippet: 'Do not show a snippet (nosnippet)', + noimageindex: 'Do not index images (noimageindex)', + unavailableAfter: 'Remove from results after', + invalidNumber: 'Enter a whole number, -1 or more.', + desktop: 'Desktop', + mobile: 'Mobile', + device: 'Preview device', + language: 'Language: :locale', + fromTemplate: 'Title from the template “:template”', + fromContent: 'Description from the content excerpt', + noDescription: 'No description: Google picks text from the page.', + noUrl: 'The address appears after the first save.', + savedNote: 'The address and the H1 come from the saved record. Save to refresh them.', + checking: 'Checking for duplicates…', + dupTitle: { one: 'Same title as :count other page: :list', other: 'Same title as :count other pages: :list' }, + dupDescription: { one: 'Same description as :count other page: :list', other: 'Same description as :count other pages: :list' }, + andMore: 'and :count more', + keyphrase: 'Focus keyphrase “:phrase”', + kpTitle: 'In the SEO title', + kpTitleNo: 'Not in the SEO title', + kpH1: 'In the H1', + kpH1No: 'Not in the H1', + kpDesc: 'In the meta description', + kpDescNo: 'Not in the meta description', + kpUrl: 'In the address', + kpUrlNo: 'Not in the address', + kpLength: 'Text length: :count words', + kpLengthNo: 'Text length: :count words, aim for at least 300', + contextFailed: 'The preview data could not be loaded.', + imageUpload: 'Upload image', + imageReplace: 'Replace image', + imageRemove: 'Remove', + imageUploading: 'Uploading…', + imageFailed: 'The image could not be uploaded.', + imageAlt: 'Social image preview', +} + +const pl: Labels = { + count: 'Znaki: :chars · :px / :max px', + good: 'Dobra długość', + emptyTitle: 'Puste: używany jest szablon tytułu', + emptyDescription: 'Puste: używany jest szablon lub zajawka', + shortTitle: 'Krótki: celuj w 30–60 znaków', + shortDescription: 'Krótki: celuj w 120–160 znaków', + longTitle: 'Za długi: Google ucina tytuły po ok. 580 px', + longDescription: 'Za długi: Google ucina opisy po ok. 920 px na komputerze i 680 px na telefonie', + indexing: 'Indeksowanie', + links: 'Linki', + inherit: 'Dziedzicz', + siteDefault: 'Domyślne', + index: 'Indeksuj', + noindex: 'Nie indeksuj', + follow: 'Śledź', + nofollow: 'Nie śledź', + advanced: 'Dyrektywy zaawansowane', + advancedSet: { one: 'ustawiona :count', few: 'ustawione :count', many: 'ustawionych :count', other: 'ustawione :count' }, + maxSnippet: 'Maksymalna długość fragmentu (znaki)', + maxSnippetHint: 'Puste: bez limitu. 0: bez fragmentu. -1: bez limitu.', + maxImagePreview: 'Rozmiar podglądu obrazu', + notSet: 'Nie ustawiono', + none: 'Brak', + standard: 'Standardowy', + large: 'Duży', + maxVideoPreview: 'Maksymalny podgląd wideo (sekundy)', + noarchive: 'Nie pokazuj kopii (noarchive)', + nosnippet: 'Nie pokazuj fragmentu (nosnippet)', + noimageindex: 'Nie indeksuj obrazów (noimageindex)', + unavailableAfter: 'Usuń z wyników po dniu', + invalidNumber: 'Wpisz liczbę całkowitą, -1 lub większą.', + desktop: 'Komputer', + mobile: 'Telefon', + device: 'Urządzenie podglądu', + language: 'Język: :locale', + fromTemplate: 'Tytuł z szablonu „:template”', + fromContent: 'Opis z zajawki treści', + noDescription: 'Brak opisu: Google wybierze tekst ze strony.', + noUrl: 'Adres pojawi się po pierwszym zapisie.', + savedNote: 'Adres i H1 pochodzą z zapisanego rekordu. Zapisz, aby je odświeżyć.', + checking: 'Sprawdzanie duplikatów…', + dupTitle: { + one: 'Ten sam tytuł co :count inna strona: :list', + few: 'Ten sam tytuł co :count inne strony: :list', + many: 'Ten sam tytuł co :count innych stron: :list', + other: 'Ten sam tytuł co :count innej strony: :list', + }, + dupDescription: { + one: 'Ten sam opis co :count inna strona: :list', + few: 'Ten sam opis co :count inne strony: :list', + many: 'Ten sam opis co :count innych stron: :list', + other: 'Ten sam opis co :count innej strony: :list', + }, + andMore: 'i :count więcej', + keyphrase: 'Fraza kluczowa „:phrase”', + kpTitle: 'W tytule SEO', + kpTitleNo: 'Brak w tytule SEO', + kpH1: 'W nagłówku H1', + kpH1No: 'Brak w nagłówku H1', + kpDesc: 'W opisie meta', + kpDescNo: 'Brak w opisie meta', + kpUrl: 'W adresie', + kpUrlNo: 'Brak w adresie', + kpLength: 'Długość tekstu: :count słów', + kpLengthNo: 'Długość tekstu: :count słów, celuj w co najmniej 300', + contextFailed: 'Nie udało się wczytać danych podglądu.', + imageUpload: 'Wgraj obraz', + imageReplace: 'Zmień obraz', + imageRemove: 'Usuń', + imageUploading: 'Wgrywanie…', + imageFailed: 'Nie udało się wgrać obrazu.', + imageAlt: 'Podgląd obrazu w mediach społecznościowych', +} + +const dictionaries: Record = { en, pl } + +/** The interface language of an element: its ui-locale, else the page's. */ +export function uiLocaleOf(el: Element): string { + const raw = el.getAttribute('ui-locale') || document.documentElement.lang || 'en' + return raw.toLowerCase().split(/[-_]/)[0] || 'en' +} + +/** The dictionary of a ui-locale; unknown languages get English. */ +export function labelsFor(locale: string): Labels { + return dictionaries[locale] ?? en +} + +/** Replaces :name placeholders, longest names first. */ +export function fmt(text: string, params: Record = {}): string { + const names = Object.keys(params).sort((a, b) => b.length - a.length) + let out = text + for (const name of names) { + out = out.split(':' + name).join(String(params[name])) + } + return out +} + +/** Picks the CLDR plural form of count in locale and fills :count. */ +export function plural(forms: Plural, count: number, locale: string, params: Record = {}): string { + let category: Intl.LDMLPluralRule = 'other' + try { + category = new Intl.PluralRules(locale).select(count) + } catch { + category = count === 1 ? 'one' : 'other' + } + const text = (category === 'one' && forms.one) || (category === 'few' && forms.few) || (category === 'many' && forms.many) || forms.other + return fmt(text, { ...params, count: new Intl.NumberFormat(locale).format(count) }) +} + +/** Formats a number in the ui-locale. */ +export function num(value: number, locale: string): string { + try { + return new Intl.NumberFormat(locale).format(value) + } catch { + return String(value) + } +} + +const SVG = 'http://www.w3.org/2000/svg' + +/** The lucide icons the elements draw, as path data on a 24px grid. */ +const ICONS: Record = { + info: ['M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20z', 'M12 16v-4', 'M12 8h.01'], + 'triangle-alert': [ + 'm21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3', + 'M12 9v4', + 'M12 17h.01', + ], + 'circle-check': ['M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20z', 'm9 12 2 2 4-4'], + 'circle-alert': ['M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20z', 'M12 8v4', 'M12 16h.01'], + 'chevron-down': ['m6 9 6 6 6-6'], +} + +/** An aria-hidden inline SVG icon of size px. */ +export function icon(name: string, size = 14): SVGSVGElement { + const svg = document.createElementNS(SVG, 'svg') + svg.setAttribute('width', String(size)) + svg.setAttribute('height', String(size)) + svg.setAttribute('viewBox', '0 0 24 24') + svg.setAttribute('fill', 'none') + svg.setAttribute('stroke', 'currentColor') + svg.setAttribute('stroke-width', '2') + svg.setAttribute('stroke-linecap', 'round') + svg.setAttribute('stroke-linejoin', 'round') + svg.setAttribute('aria-hidden', 'true') + svg.setAttribute('class', 'seo-icon seo-icon--' + name) + for (const d of ICONS[name] ?? []) { + const path = document.createElementNS(SVG, 'path') + path.setAttribute('d', d) + svg.appendChild(path) + } + return svg +} + +/** Creates an element with a class and optional text (set as textContent). */ +export function el(tag: K, className = '', text?: string): HTMLElementTagNameMap[K] { + const node = document.createElement(tag) + if (className) { + node.className = className + } + if (text !== undefined) { + node.textContent = text + } + return node +} + +/** Reads a JSON attribute; invalid or absent JSON gives fallback. */ +export function jsonAttr(element: Element, name: string, fallback: T): T { + const raw = element.getAttribute(name) + if (raw === null || raw === '') { + return fallback + } + try { + return JSON.parse(raw) as T + } catch { + return fallback + } +} + +/** Dispatches a bubbling, composed CustomEvent. */ +export function emit(target: EventTarget, type: string, detail: unknown): void { + target.dispatchEvent(new CustomEvent(type, { detail, bubbles: true, composed: true })) +} + +let sequence = 0 + +/** A document-unique id for aria references. */ +export function uid(prefix: string): string { + sequence += 1 + return `${prefix}-${sequence}` +} diff --git a/assets/src/elements/measure.ts b/assets/src/elements/measure.ts new file mode 100644 index 0000000..4df65a2 --- /dev/null +++ b/assets/src/elements/measure.ts @@ -0,0 +1,106 @@ +// Pixel measurement of result-preview text (D-09, Q-3). Google renders +// titles in 20px Arial and descriptions in 14px Arial; the limits are the +// widths after which it truncates. + +export const TITLE_FONT = '20px Arial, sans-serif' +export const DESCRIPTION_FONT = '14px Arial, sans-serif' +export const TITLE_MAX_PX = 580 +export const DESCRIPTION_MAX_PX = 920 +export const DESCRIPTION_MOBILE_MAX_PX = 680 +export const TITLE_SHORT_CHARS = 30 +export const DESCRIPTION_SHORT_CHARS = 120 + +export type Measure = 'title' | 'description' +export type Status = 'empty' | 'short' | 'long' | 'good' + +let context: CanvasRenderingContext2D | null | undefined + +function canvas(): CanvasRenderingContext2D | null { + if (context !== undefined) { + return context + } + try { + const c = document.createElement('canvas') + const ctx = c.getContext('2d') + context = ctx && typeof ctx.measureText === 'function' ? ctx : null + } catch { + context = null + } + return context +} + +/** Average Arial advance per character class, in em, when no canvas is available. */ +function estimate(text: string, sizePx: number): number { + let em = 0 + for (const ch of text) { + if (/[ilIj.,:;'!|]/.test(ch)) { + em += 0.28 + } else if (/[mwMW@]/.test(ch)) { + em += 0.85 + } else if (/[A-Z]/.test(ch)) { + em += 0.67 + } else if (ch === ' ') { + em += 0.28 + } else { + em += 0.53 + } + } + return em * sizePx +} + +/** The width of text in font, in CSS pixels. */ +export function textWidth(text: string, font: string): number { + const ctx = canvas() + if (ctx) { + ctx.font = font + const width = ctx.measureText(text).width + if (Number.isFinite(width) && (width > 0 || text === '')) { + return width + } + } + const size = Number.parseFloat(font) || 14 + return estimate(text, size) +} + +/** text cut to fit maxPx with an ellipsis, or text itself when it fits. */ +export function truncate(text: string, font: string, maxPx: number): string { + if (textWidth(text, font) <= maxPx) { + return text + } + const chars = Array.from(text) + let lo = 0 + let hi = chars.length + while (lo < hi) { + const mid = Math.ceil((lo + hi) / 2) + if (textWidth(chars.slice(0, mid).join('').trimEnd() + '…', font) <= maxPx) { + lo = mid + } else { + hi = mid - 1 + } + } + return chars.slice(0, lo).join('').trimEnd() + '…' +} + +/** The font and pixel limit of a measured field. */ +export function limits(measure: Measure): { font: string; maxPx: number; shortChars: number } { + return measure === 'title' + ? { font: TITLE_FONT, maxPx: TITLE_MAX_PX, shortChars: TITLE_SHORT_CHARS } + : { font: DESCRIPTION_FONT, maxPx: DESCRIPTION_MAX_PX, shortChars: DESCRIPTION_SHORT_CHARS } +} + +/** The C4 status of a value: pixels decide "long", characters "short". */ +export function status(value: string, measure: Measure): { status: Status; chars: number; px: number; maxPx: number } { + const { font, maxPx, shortChars } = limits(measure) + const trimmed = value.trim() + const chars = Array.from(trimmed).length + const px = Math.round(textWidth(trimmed, font)) + let s: Status = 'good' + if (trimmed === '') { + s = 'empty' + } else if (px > maxPx) { + s = 'long' + } else if (chars < shortChars) { + s = 'short' + } + return { status: s, chars, px, maxPx } +} diff --git a/assets/src/elements/robots.ts b/assets/src/elements/robots.ts new file mode 100644 index 0000000..b6bf3aa --- /dev/null +++ b/assets/src/elements/robots.ts @@ -0,0 +1,357 @@ +import { el, emit, icon, jsonAttr, labelsFor, plural, uid, uiLocaleOf, type Labels } from './labels' + +export const ROBOTS_TAG = 'golem15-seo-robots' + +/** + * A robots value (UI-SPEC C5): comma-separated directives in the fixed + * order index|noindex, follow|nofollow, noarchive, nosnippet, noimageindex, + * max-snippet:N, max-image-preview:none|standard|large, + * max-video-preview:N, unavailable_after:YYYY-MM-DD. The grammar is the + * same as classes/robots_value.go; empty inherits everything. + */ +export interface Robots { + index: '' | 'index' | 'noindex' + follow: '' | 'follow' | 'nofollow' + noarchive: boolean + nosnippet: boolean + noimageindex: boolean + maxSnippet: number | null + maxImagePreview: '' | 'none' | 'standard' | 'large' + maxVideoPreview: number | null + unavailableAfter: string +} + +export function emptyRobots(): Robots { + return { + index: '', + follow: '', + noarchive: false, + nosnippet: false, + noimageindex: false, + maxSnippet: null, + maxImagePreview: '', + maxVideoPreview: null, + unavailableAfter: '', + } +} + +/** Parses a robots value; null when it is malformed. */ +export function parseRobots(value: string): Robots | null { + const r = emptyRobots() + const trimmed = value.trim() + if (trimmed === '') { + return r + } + const seen = new Set() + for (const part of trimmed.split(',')) { + const d = part.trim().toLowerCase() + if (d === '') { + return null + } + const colon = d.indexOf(':') + const name = (colon < 0 ? d : d.slice(0, colon)).trim() + const arg = colon < 0 ? null : d.slice(colon + 1).trim() + const group = name === 'noindex' ? 'index' : name === 'nofollow' ? 'follow' : name + if (seen.has(group)) { + return null + } + seen.add(group) + switch (name) { + case 'index': + case 'noindex': + if (arg !== null) return null + r.index = name + break + case 'follow': + case 'nofollow': + if (arg !== null) return null + r.follow = name + break + case 'noarchive': + case 'nosnippet': + case 'noimageindex': + if (arg !== null) return null + r[name] = true + break + case 'max-snippet': + case 'max-video-preview': { + if (arg === null || !/^-?\d+$/.test(arg)) return null + const n = Number.parseInt(arg, 10) + if (n < -1) return null + if (name === 'max-snippet') r.maxSnippet = n + else r.maxVideoPreview = n + break + } + case 'max-image-preview': + if (arg !== 'none' && arg !== 'standard' && arg !== 'large') return null + r.maxImagePreview = arg + break + case 'unavailable_after': + if (arg === null || !/^\d{4}-\d{2}-\d{2}$/.test(arg) || Number.isNaN(Date.parse(arg + 'T00:00:00Z'))) return null + r.unavailableAfter = arg + break + default: + return null + } + } + return r +} + +/** Serialises a robots value in the fixed order, joined by ",". */ +export function serializeRobots(r: Robots): string { + const out: string[] = [] + if (r.index) out.push(r.index) + if (r.follow) out.push(r.follow) + if (r.noarchive) out.push('noarchive') + if (r.nosnippet) out.push('nosnippet') + if (r.noimageindex) out.push('noimageindex') + if (r.maxSnippet !== null) out.push('max-snippet:' + r.maxSnippet) + if (r.maxImagePreview) out.push('max-image-preview:' + r.maxImagePreview) + if (r.maxVideoPreview !== null) out.push('max-video-preview:' + r.maxVideoPreview) + if (r.unavailableAfter) out.push('unavailable_after:' + r.unavailableAfter) + return out.join(',') +} + +/** The number of advanced directives set. */ +export function advancedCount(r: Robots): number { + return [r.noarchive, r.nosnippet, r.noimageindex, r.maxSnippet !== null, r.maxImagePreview !== '', r.maxVideoPreview !== null, r.unavailableAfter !== ''].filter(Boolean).length +} + +/** + * E2 golem15-seo-robots (UI-SPEC C5): two segmented radio groups + * (indexing, links) and an advanced disclosure; the value is the robots + * string, emitted with summer-change on every edit. + */ +export class SeoRobotsElement extends HTMLElement { + static observedAttributes = ['options', 'disabled', 'invalid', 'ui-locale', 'locale'] + + private current = '' + private model: Robots = emptyRobots() + private open = false + + get value(): string { + return this.current + } + + set value(v: unknown) { + const next = typeof v === 'string' ? v : v == null ? '' : String(v) + if (next === this.current && this.childElementCount > 0) { + return + } + this.current = next + this.model = parseRobots(next) ?? emptyRobots() + this.open = this.open || advancedCount(this.model) > 0 + this.render() + } + + connectedCallback(): void { + this.open = advancedCount(this.model) > 0 + this.render() + } + + attributeChangedCallback(): void { + if (this.isConnected) { + this.render() + } + } + + private labels(): Labels { + return labelsFor(uiLocaleOf(this)) + } + + private commit(): void { + this.current = serializeRobots(this.model) + emit(this, 'summer-change', { value: this.current }) + this.render() + } + + private render(): void { + const labels = this.labels() + const disabled = this.hasAttribute('disabled') + const options = jsonAttr>(this, 'options', {}) + const inheritLabel = options.scope === 'type' ? labels.siteDefault : labels.inherit + this.classList.toggle('seo-robots--invalid', this.hasAttribute('invalid')) + const focused = document.activeElement && this.contains(document.activeElement) ? (document.activeElement as HTMLElement).dataset.focusKey : undefined + + const groups = el('div', 'seo-robots__groups') + groups.append( + this.radioGroup(labels.indexing, 'index', [ + ['', inheritLabel, 'inherit'], + ['index', labels.index, 'ok'], + ['noindex', labels.noindex, 'danger'], + ], disabled), + this.radioGroup(labels.links, 'follow', [ + ['', inheritLabel, 'inherit'], + ['follow', labels.follow, 'ok'], + ['nofollow', labels.nofollow, 'danger'], + ], disabled), + ) + + const panelId = uid('seo-robots-advanced') + const toggle = el('button', 'seo-robots__toggle') + toggle.type = 'button' + toggle.setAttribute('aria-expanded', this.open ? 'true' : 'false') + toggle.setAttribute('aria-controls', panelId) + toggle.dataset.focusKey = 'toggle' + toggle.append(el('span', '', labels.advanced), icon('chevron-down')) + toggle.addEventListener('click', () => { + this.open = !this.open + this.render() + }) + const row = el('div', 'seo-robots__advanced-row') + row.append(toggle) + const count = advancedCount(this.model) + if (count > 0) { + row.append(el('span', 'seo-robots__summary', plural(labels.advancedSet, count, uiLocaleOf(this)))) + } + + const panel = el('div', 'seo-robots__panel') + panel.id = panelId + panel.hidden = !this.open + panel.append( + this.numberField(labels.maxSnippet, labels.maxSnippetHint, 'maxSnippet', disabled, labels), + this.selectField(labels, disabled), + this.numberField(labels.maxVideoPreview, '', 'maxVideoPreview', disabled, labels), + this.dateField(labels, disabled), + ) + const checks = el('div', 'seo-robots__checks') + for (const key of ['noarchive', 'nosnippet', 'noimageindex'] as const) { + const label = el('label', 'seo-robots__check') + const box = el('input') + box.type = 'checkbox' + box.checked = this.model[key] + box.disabled = disabled + box.dataset.focusKey = key + box.addEventListener('change', () => { + this.model[key] = box.checked + this.commit() + }) + label.append(box, el('span', '', labels[key])) + checks.append(label) + } + panel.append(checks) + + this.replaceChildren(groups, row, panel) + if (focused) { + this.querySelector(`[data-focus-key="${focused}"]`)?.focus() + } + } + + private radioGroup(caption: string, key: 'index' | 'follow', items: [string, string, string][], disabled: boolean): HTMLElement { + const wrap = el('div', 'seo-robots__group') + const captionId = uid('seo-robots-caption') + const cap = el('span', 'seo-robots__caption', caption) + cap.id = captionId + const track = el('div', 'seo-robots__track') + track.setAttribute('role', 'radiogroup') + track.setAttribute('aria-labelledby', captionId) + const current = this.model[key] + items.forEach(([value, text, tone], i) => { + const seg = el('button', 'seo-robots__segment seo-robots__segment--' + tone, text) + seg.type = 'button' + seg.setAttribute('role', 'radio') + const checked = current === value + seg.setAttribute('aria-checked', checked ? 'true' : 'false') + seg.tabIndex = checked ? 0 : -1 + seg.disabled = disabled + seg.dataset.focusKey = key + '-' + i + seg.addEventListener('click', () => this.choose(key, value)) + seg.addEventListener('keydown', (ev) => { + const delta = ev.key === 'ArrowRight' || ev.key === 'ArrowDown' ? 1 : ev.key === 'ArrowLeft' || ev.key === 'ArrowUp' ? -1 : 0 + if (delta === 0 || disabled) { + return + } + ev.preventDefault() + const next = (i + delta + items.length) % items.length + this.choose(key, items[next][0]) + this.querySelector(`[data-focus-key="${key}-${next}"]`)?.focus() + }) + track.append(seg) + }) + wrap.append(cap, track) + return wrap + } + + private choose(key: 'index' | 'follow', value: string): void { + if (this.hasAttribute('disabled')) { + return + } + if (key === 'index') { + this.model.index = value as Robots['index'] + } else { + this.model.follow = value as Robots['follow'] + } + this.commit() + } + + private numberField(caption: string, hint: string, key: 'maxSnippet' | 'maxVideoPreview', disabled: boolean, labels: Labels): HTMLElement { + const wrap = el('label', 'seo-robots__field') + wrap.append(el('span', 'seo-robots__label', caption)) + const input = el('input', 'seo-robots__input') + input.type = 'number' + input.min = '-1' + input.step = '1' + input.disabled = disabled + input.dataset.focusKey = key + const v = this.model[key] + input.value = v === null ? '' : String(v) + const error = el('span', 'seo-robots__error') + error.setAttribute('role', 'status') + input.addEventListener('change', () => { + const raw = input.value.trim() + if (raw === '') { + this.model[key] = null + this.commit() + return + } + if (!/^-?\d+$/.test(raw) || Number.parseInt(raw, 10) < -1) { + error.textContent = labels.invalidNumber + return + } + this.model[key] = Number.parseInt(raw, 10) + this.commit() + }) + wrap.append(input) + if (hint) { + wrap.append(el('span', 'seo-robots__hint', hint)) + } + wrap.append(error) + return wrap + } + + private selectField(labels: Labels, disabled: boolean): HTMLElement { + const wrap = el('label', 'seo-robots__field') + wrap.append(el('span', 'seo-robots__label', labels.maxImagePreview)) + const select = el('select', 'seo-robots__input') + select.disabled = disabled + select.dataset.focusKey = 'maxImagePreview' + for (const [value, text] of [['', labels.notSet], ['none', labels.none], ['standard', labels.standard], ['large', labels.large]] as const) { + const option = el('option', '', text) + option.value = value + option.selected = this.model.maxImagePreview === value + select.append(option) + } + select.addEventListener('change', () => { + this.model.maxImagePreview = select.value as Robots['maxImagePreview'] + this.commit() + }) + wrap.append(select) + return wrap + } + + private dateField(labels: Labels, disabled: boolean): HTMLElement { + const wrap = el('label', 'seo-robots__field') + wrap.append(el('span', 'seo-robots__label', labels.unavailableAfter)) + const input = el('input', 'seo-robots__input') + input.type = 'date' + input.disabled = disabled + input.dataset.focusKey = 'unavailableAfter' + input.value = this.model.unavailableAfter + input.addEventListener('change', () => { + this.model.unavailableAfter = /^\d{4}-\d{2}-\d{2}$/.test(input.value) ? input.value : '' + this.commit() + }) + wrap.append(input) + return wrap + } +} diff --git a/assets/src/elements/serp.ts b/assets/src/elements/serp.ts new file mode 100644 index 0000000..2aceae9 --- /dev/null +++ b/assets/src/elements/serp.ts @@ -0,0 +1,434 @@ +import { el, emit, fmt, icon, jsonAttr, labelsFor, plural, uiLocaleOf, type Labels } from './labels' +import { DESCRIPTION_FONT, DESCRIPTION_MAX_PX, DESCRIPTION_MOBILE_MAX_PX, TITLE_FONT, TITLE_MAX_PX, truncate } from './measure' + +export const SERP_TAG = 'golem15-seo-serp' + +/** One locale of the seo_snippet context answer. */ +export interface ContextLocale { + locale: string + url: string | null + h1: string | null + siteName: string + titleTemplate: string + defaultTitle: string + defaultDescription: string | null + descriptionSource: string + bodyWords: number | null +} + +interface ContextData { + op: 'context' + host: string + locales: ContextLocale[] +} + +interface DuplicateItem { + label: string + url: string +} + +interface DuplicatesData { + op: 'duplicates' + locale: string + title: DuplicateItem[] + description: DuplicateItem[] +} + +type Payload = { op: 'context' } | { op: 'duplicates'; locale: string; title: string; description: string } + +const DUPLICATE_DELAY_MS = 800 +const MAX_LINKS = 3 +const LENGTH_WORDS = 300 + +/** Fills a title template the way classes/templates.go does. */ +export function renderTemplate(tpl: string, vars: { title: string; description?: string; site: string }): string { + if (tpl.trim() === '') { + return '' + } + const out = tpl + .split('%title%').join(vars.title) + .split('%description%').join(vars.description ?? '') + .split('%site%').join(vars.site) + return out.split(/\s+/).filter(Boolean).join(' ').replace(/^[\s|\-–—·:,]+|[\s|\-–—·:,]+$/gu, '') +} + +/** Lowercases and strips diacritics (ł becomes l) for keyphrase checks. */ +export function normalizeText(s: string): string { + return s + .toLowerCase() + .replace(/ł/g, 'l') + .normalize('NFD') + .replace(/\p{M}/gu, '') +} + +/** The words of a text after normalizeText. */ +export function words(s: string): string[] { + return normalizeText(s).split(/[^\p{L}\p{N}]+/u).filter(Boolean) +} + +/** + * Q-4 stem rule: two words match when equal, or when both have at least 5 + * characters and share a prefix of at least max(5, shorter length − 3) + * characters ("grzyby" matches "grzybów"). + */ +export function wordsMatch(a: string, b: string): boolean { + if (a === b) { + return true + } + const ca = Array.from(a) + const cb = Array.from(b) + if (ca.length < 5 || cb.length < 5) { + return false + } + const need = Math.max(5, Math.min(ca.length, cb.length) - 3) + let shared = 0 + while (shared < ca.length && shared < cb.length && ca[shared] === cb[shared]) { + shared++ + } + return shared >= need +} + +/** Whether the phrase's words appear in text in order (C6 matching). */ +export function phraseIn(phrase: string, text: string): boolean { + const p = words(phrase) + if (p.length === 0) { + return false + } + const t = words(text) + for (let start = 0; start + p.length <= t.length; start++) { + let ok = true + for (let i = 0; i < p.length; i++) { + if (!wordsMatch(p[i], t[start + i])) { + ok = false + break + } + } + if (ok) { + return true + } + } + return false +} + +/** A locale map or a plain value read for one locale. */ +function valueIn(fill: Record, key: string, locale: string): string { + const v = fill[key] + if (typeof v === 'string') { + return v + } + if (v && typeof v === 'object') { + const text = (v as Record)[locale] + return typeof text === 'string' ? text : '' + } + return '' +} + +/** + * E3 golem15-seo-serp (UI-SPEC C6): the live search-result preview widget + * of the SEO tab and the URL rule form, talking to the seo_snippet widget + * action through summer-action only. + */ +export class SeoSerpElement extends HTMLElement { + static observedAttributes = ['fill-values', 'busy', 'state', 'record-id', 'ui-locale', 'locale', 'disabled'] + + private context: ContextData | null = null + private contextFailed = false + private device: 'desktop' | 'mobile' = 'desktop' + private shownLocale = '' + private inflight: Payload | null = null + private queued: Payload | null = null + private duplicates = new Map() + private checking = false + private timer: ReturnType | undefined + private lastChecked = '' + private connected = false + + private readonly onResult = (ev: Event): void => { + const detail = (ev as CustomEvent).detail as { data?: unknown } | undefined + const data = detail?.data as ContextData | DuplicatesData | undefined + this.inflight = null + if (data && data.op === 'context') { + this.context = data + this.contextFailed = false + this.render() + this.scheduleDuplicates(true) + } else if (data && data.op === 'duplicates') { + this.duplicates.set(data.locale, data) + this.checking = false + this.render() + } + this.flush() + } + + private readonly onLocale = (ev: Event): void => { + const locale = (ev as CustomEvent).detail + if (typeof locale === 'string' && locale !== '' && locale !== this.shownLocale) { + this.shownLocale = locale + this.render() + this.scheduleDuplicates(false) + } + } + + connectedCallback(): void { + this.connected = true + this.addEventListener('summer-result', this.onResult) + window.addEventListener('summer:ml-locale', this.onLocale) + this.render() + this.request({ op: 'context' }) + } + + disconnectedCallback(): void { + this.connected = false + this.removeEventListener('summer-result', this.onResult) + window.removeEventListener('summer:ml-locale', this.onLocale) + clearTimeout(this.timer) + } + + attributeChangedCallback(name: string, _old: string | null, value: string | null): void { + if (!this.connected) { + return + } + if (name === 'busy' && value === null) { + this.flush() + return + } + if (name === 'state' && value === 'error') { + if (this.inflight?.op === 'context') { + this.contextFailed = true + } + this.checking = false + this.inflight = null + this.render() + return + } + if (name === 'fill-values') { + this.render() + this.scheduleDuplicates(false) + return + } + this.render() + } + + private labels(): Labels { + return labelsFor(uiLocaleOf(this)) + } + + /** Sends payload, or queues it (one at most) while a request runs. */ + private request(payload: Payload): void { + if (this.hasAttribute('busy') || this.inflight) { + this.queued = payload + return + } + this.inflight = payload + emit(this, 'summer-action', { payload }) + } + + private flush(): void { + if (this.hasAttribute('busy') || this.inflight || !this.queued) { + return + } + const next = this.queued + this.queued = null + this.request(next) + } + + private fill(): Record { + return jsonAttr>(this, 'fill-values', {}) + } + + private locale(): ContextLocale | null { + const locales = this.context?.locales ?? [] + if (locales.length === 0) { + return null + } + return locales.find((l) => l.locale === this.shownLocale) ?? locales[0] + } + + private scheduleDuplicates(now: boolean): void { + const loc = this.locale() + if (!loc) { + return + } + const fill = this.fill() + const title = valueIn(fill, 'seo_title', loc.locale) || valueIn(fill, 'title', loc.locale) + const description = valueIn(fill, 'seo_description', loc.locale) || valueIn(fill, 'description', loc.locale) + const key = JSON.stringify([loc.locale, title, description]) + if (key === this.lastChecked) { + return + } + clearTimeout(this.timer) + const send = (): void => { + this.lastChecked = key + this.checking = true + this.render() + this.request({ op: 'duplicates', locale: loc.locale, title, description }) + } + if (now) { + send() + } else { + this.timer = setTimeout(send, DUPLICATE_DELAY_MS) + } + } + + private render(): void { + const labels = this.labels() + const ui = uiLocaleOf(this) + if (!this.context && !this.contextFailed) { + this.setAttribute('aria-busy', 'true') + this.replaceChildren(el('div', 'seo-serp__skeleton')) + return + } + this.removeAttribute('aria-busy') + const loc = this.locale() + const localeCode = loc?.locale ?? this.shownLocale + const fill = this.fill() + const seoTitle = (valueIn(fill, 'seo_title', localeCode) || valueIn(fill, 'title', localeCode)).trim() + const seoDescription = (valueIn(fill, 'seo_description', localeCode) || valueIn(fill, 'description', localeCode)).trim() + const keyphrase = valueIn(fill, 'seo_keyphrase', localeCode).trim() + const siteName = loc?.siteName ?? '' + const title = seoTitle || renderTemplate(loc?.titleTemplate ?? '', { title: loc?.defaultTitle ?? '', site: siteName }) || loc?.defaultTitle || '' + const description = seoDescription || loc?.defaultDescription || '' + + const header = el('div', 'seo-serp__header') + const track = el('div', 'seo-serp__track') + track.setAttribute('role', 'radiogroup') + track.setAttribute('aria-label', labels.device) + for (const device of ['desktop', 'mobile'] as const) { + const seg = el('button', 'seo-serp__segment', labels[device]) + seg.type = 'button' + seg.setAttribute('role', 'radio') + seg.setAttribute('aria-checked', this.device === device ? 'true' : 'false') + seg.addEventListener('click', () => { + this.device = device + this.render() + }) + track.append(seg) + } + header.append(track) + if (localeCode) { + header.append(el('span', 'seo-serp__language', fmt(labels.language, { locale: localeCode.toUpperCase() }))) + } + + const card = el('div', 'serp-card' + (this.device === 'mobile' ? ' serp-card--mobile' : '')) + const top = el('div', 'serp-card__site') + top.append(el('span', 'serp-card__favicon', Array.from(siteName || this.context?.host || '?')[0]?.toUpperCase() ?? '?')) + const names = el('div', 'serp-card__names') + names.append(el('span', 'serp-card__sitename', siteName || this.context?.host || '')) + names.append(el('span', 'serp-card__crumbs', breadcrumb(this.context?.host ?? '', loc?.url ?? null))) + top.append(names) + card.append(top) + card.append(el('div', 'serp-card__title', truncate(title, TITLE_FONT, TITLE_MAX_PX))) + if (description) { + const max = this.device === 'mobile' ? DESCRIPTION_MOBILE_MAX_PX : DESCRIPTION_MAX_PX + card.append(el('div', 'serp-card__description', truncate(description, DESCRIPTION_FONT, max))) + } else { + card.append(el('div', 'serp-card__description serp-card__description--missing', labels.noDescription)) + } + + const notes = el('div', 'seo-serp__notes') + if (this.contextFailed) { + const fail = el('div', 'seo-serp__error') + fail.setAttribute('role', 'status') + fail.append(icon('circle-alert'), el('span', '', labels.contextFailed)) + notes.append(fail) + } + if (!seoTitle && loc?.titleTemplate) { + notes.append(el('div', '', fmt(labels.fromTemplate, { template: loc.titleTemplate }))) + } + if (!seoDescription && loc?.defaultDescription && loc.descriptionSource === 'content') { + notes.append(el('div', '', labels.fromContent)) + } + if (loc && loc.url === null) { + notes.append(el('div', '', labels.noUrl)) + } + if ((this.getAttribute('record-id') ?? '') !== '' && loc) { + notes.append(el('div', '', labels.savedNote)) + } + + const checks = el('div', 'seo-serp__checks') + checks.setAttribute('role', 'status') + if (this.checking) { + checks.append(el('div', 'seo-serp__checking', labels.checking)) + } else { + const dup = this.duplicates.get(localeCode) + if (dup) { + for (const [items, forms] of [[dup.title, labels.dupTitle], [dup.description, labels.dupDescription]] as const) { + if (items.length > 0) { + checks.append(duplicateLine(items, forms, labels, ui)) + } + } + } + } + + const parts: HTMLElement[] = [header, card, notes, checks] + if (keyphrase && loc) { + parts.push(keyphraseChecks(keyphrase, { title, description, h1: loc.h1, url: loc.url, bodyWords: loc.bodyWords }, labels, ui)) + } + this.replaceChildren(...parts) + } +} + +/** host › segment › segment of a path. */ +function breadcrumb(host: string, url: string | null): string { + const segments = (url ?? '').split('?')[0].split('/').filter(Boolean).map((s) => { + try { + return decodeURIComponent(s) + } catch { + return s + } + }) + return [host, ...segments].filter(Boolean).join(' › ') +} + +function duplicateLine(items: DuplicateItem[], forms: Labels['dupTitle'], labels: Labels, ui: string): HTMLElement { + const line = el('div', 'seo-serp__check seo-serp__check--warn') + line.append(icon('triangle-alert')) + const text = el('span') + const marker = '\u0000list\u0000' + const sentence = plural(forms, items.length, ui, { list: marker }) + const [before, after] = sentence.split(marker) + text.append(document.createTextNode(before ?? '')) + items.slice(0, MAX_LINKS).forEach((item, i) => { + if (i > 0) { + text.append(document.createTextNode(', ')) + } + if (item.url.startsWith('/') && !item.url.startsWith('//')) { + const a = el('a', 'seo-serp__link', item.label) + a.href = item.url + a.target = '_blank' + a.rel = 'noopener' + text.append(a) + } else { + text.append(document.createTextNode(item.label)) + } + }) + if (items.length > MAX_LINKS) { + text.append(document.createTextNode(' ' + fmt(labels.andMore, { count: items.length - MAX_LINKS }))) + } + text.append(document.createTextNode(after ?? '')) + line.append(text) + return line +} + +function keyphraseChecks(phrase: string, page: { title: string; description: string; h1: string | null; url: string | null; bodyWords: number | null }, labels: Labels, ui: string): HTMLElement { + const box = el('div', 'seo-serp__keyphrase') + box.append(el('div', 'seo-serp__keyphrase-title', fmt(labels.keyphrase, { phrase }))) + const line = (ok: boolean, yes: string, no: string): void => { + const row = el('div', 'seo-serp__check ' + (ok ? 'seo-serp__check--ok' : 'seo-serp__check--warn')) + row.append(icon(ok ? 'circle-check' : 'triangle-alert'), el('span', '', ok ? yes : no)) + box.append(row) + } + line(phraseIn(phrase, page.title), labels.kpTitle, labels.kpTitleNo) + if (page.h1 !== null) { + line(phraseIn(phrase, page.h1), labels.kpH1, labels.kpH1No) + } + line(phraseIn(phrase, page.description), labels.kpDesc, labels.kpDescNo) + if (page.url !== null) { + line(phraseIn(phrase, page.url.replace(/[-_/]+/g, ' ')), labels.kpUrl, labels.kpUrlNo) + } + if (page.bodyWords !== null) { + const count = new Intl.NumberFormat(ui).format(page.bodyWords) + line(page.bodyWords >= LENGTH_WORDS, fmt(labels.kpLength, { count }), fmt(labels.kpLengthNo, { count })) + } + return box +} diff --git a/assets/src/elements/styles.css b/assets/src/elements/styles.css new file mode 100644 index 0000000..2e27901 --- /dev/null +++ b/assets/src/elements/styles.css @@ -0,0 +1,459 @@ +/* SEO field elements (UI-SPEC C4, C5, C6, C14). Light DOM, every rule + scoped under its element tag, colours from the public --c-* variables + only (except the E-1 mimic palette inside golem15-seo-serp .serp-card). */ + +golem15-seo-text, +golem15-seo-robots, +golem15-seo-serp, +golem15-seo-image { + display: block; + color: var(--c-text); + font-size: 14px; +} + +golem15-seo-text .seo-visually-hidden, +golem15-seo-image .seo-visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} + +golem15-seo-text .seo-text__control { + box-sizing: border-box; + width: 100%; + height: 44px; + padding: 0 14px; + border: 1px solid var(--c-border-strong); + border-radius: 10px; + background: var(--c-surface); + color: var(--c-text); + font: inherit; + font-size: 14px; + transition: border-color 150ms, box-shadow 150ms; +} + +golem15-seo-text .seo-text__control--area { + height: auto; + min-height: 96px; + padding: 10px 14px; + resize: vertical; +} + +golem15-seo-text .seo-text__control:focus { + outline: none; + border-color: var(--c-primary); + box-shadow: 0 0 0 3px var(--c-ring); +} + +golem15-seo-text.seo-text--invalid .seo-text__control { + border-color: var(--c-danger); +} + +golem15-seo-text .seo-text__counter { + display: flex; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; + margin-top: 8px; + font-size: 13px; +} + +golem15-seo-text .seo-text__status { + display: inline-flex; + align-items: center; + gap: 6px; +} + +golem15-seo-text .seo-text__icon { + display: inline-flex; +} + +golem15-seo-text .seo-text__count { + color: var(--c-muted); + font-variant-numeric: tabular-nums; +} + +golem15-seo-text[data-status='empty'] .seo-text__status { + color: var(--c-muted); +} + +golem15-seo-text[data-status='good'] .seo-text__status { + color: var(--c-ok-text, var(--c-text)); +} + +golem15-seo-robots .seo-robots__groups { + display: flex; + flex-wrap: wrap; + gap: 24px; +} + +golem15-seo-robots .seo-robots__group { + display: flex; + flex-direction: column; + gap: 6px; +} + +golem15-seo-robots .seo-robots__caption, +golem15-seo-robots .seo-robots__summary, +golem15-seo-robots .seo-robots__hint { + color: var(--c-muted); + font-size: 13px; +} + +golem15-seo-robots .seo-robots__track, +golem15-seo-serp .seo-serp__track { + display: inline-flex; + gap: 4px; + padding: 4px; + border: 1px solid var(--c-border); + border-radius: 12px; + background: var(--c-subtle); +} + +golem15-seo-robots .seo-robots__segment, +golem15-seo-serp .seo-serp__segment { + height: 34px; + padding: 0 16px; + border: 0; + border-radius: 9px; + background: transparent; + color: var(--c-muted); + font: inherit; + font-size: 13px; + cursor: pointer; + transition: background-color 150ms, color 150ms; +} + +golem15-seo-robots .seo-robots__segment[aria-checked='true'], +golem15-seo-serp .seo-serp__segment[aria-checked='true'] { + font-size: 14px; + font-weight: 600; + background: var(--c-surface); + color: var(--c-text); + box-shadow: var(--shadow-tab, none); +} + +golem15-seo-robots .seo-robots__segment--ok[aria-checked='true'] { + background: var(--c-ok-bg, var(--c-surface)); + color: var(--c-ok-text, var(--c-text)); +} + +golem15-seo-robots .seo-robots__segment--danger[aria-checked='true'] { + background: var(--c-danger-soft); + color: var(--c-danger); +} + +golem15-seo-robots .seo-robots__segment:focus-visible, +golem15-seo-robots .seo-robots__toggle:focus-visible, +golem15-seo-serp .seo-serp__segment:focus-visible, +golem15-seo-serp .seo-serp__link:focus-visible, +golem15-seo-image .seo-image__button:focus-visible { + outline: 3px solid var(--c-ring); + outline-offset: 2px; +} + +golem15-seo-robots .seo-robots__advanced-row { + display: flex; + align-items: center; + gap: 12px; + margin-top: 12px; +} + +golem15-seo-robots .seo-robots__toggle { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0; + border: 0; + background: none; + color: var(--c-text); + font: inherit; + font-size: 14px; + font-weight: 600; + cursor: pointer; +} + +golem15-seo-robots .seo-robots__toggle[aria-expanded='true'] .seo-icon { + transform: rotate(180deg); +} + +golem15-seo-robots .seo-robots__panel { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + margin-top: 12px; +} + +golem15-seo-robots .seo-robots__panel[hidden] { + display: none; +} + +golem15-seo-robots .seo-robots__field { + display: flex; + flex-direction: column; + gap: 6px; +} + +golem15-seo-robots .seo-robots__input { + box-sizing: border-box; + height: 44px; + padding: 0 14px; + border: 1px solid var(--c-border-strong); + border-radius: 10px; + background: var(--c-surface); + color: var(--c-text); + font: inherit; +} + +golem15-seo-robots .seo-robots__input:focus { + outline: none; + border-color: var(--c-primary); + box-shadow: 0 0 0 3px var(--c-ring); +} + +golem15-seo-robots .seo-robots__error { + color: var(--c-danger); + font-size: 13px; +} + +golem15-seo-robots .seo-robots__checks { + grid-column: 1 / -1; + display: flex; + flex-direction: column; + gap: 8px; +} + +golem15-seo-robots .seo-robots__check { + display: inline-flex; + align-items: center; + gap: 8px; +} + +golem15-seo-robots .seo-robots__check input { + width: 20px; + height: 20px; + border-radius: 5px; + accent-color: var(--c-primary); +} + +golem15-seo-robots.seo-robots--invalid .seo-robots__track { + border-color: var(--c-danger); +} + +golem15-seo-serp .seo-serp__skeleton { + max-width: 600px; + height: 132px; + border-radius: 12px; + background: var(--c-skel); +} + +golem15-seo-serp .seo-serp__header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + flex-wrap: wrap; + margin-bottom: 12px; +} + +golem15-seo-serp .seo-serp__language, +golem15-seo-serp .seo-serp__notes { + color: var(--c-muted); + font-size: 13px; +} + +golem15-seo-serp .serp-card { + --serp-bg: #ffffff; + --serp-site: #202124; + --serp-title: #1a0dab; + --serp-text: #4d5156; + box-sizing: border-box; + max-width: 600px; + padding: 16px; + border: 1px solid var(--c-border); + border-radius: 12px; + background: var(--serp-bg); + font-family: Arial, sans-serif; + overflow-wrap: anywhere; +} + +golem15-seo-serp .serp-card--mobile { + max-width: 360px; +} + +golem15-seo-serp .serp-card__site { + display: flex; + align-items: center; + gap: 10px; +} + +golem15-seo-serp .serp-card__favicon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border: 1px solid var(--c-border); + border-radius: 50%; + background: var(--c-subtle); + color: var(--c-muted); + font-size: 14px; + font-weight: 600; +} + +golem15-seo-serp .serp-card__names { + display: flex; + flex-direction: column; + color: var(--serp-site); +} + +golem15-seo-serp .serp-card__sitename { + font-size: 14px; +} + +golem15-seo-serp .serp-card__crumbs { + font-size: 12px; +} + +golem15-seo-serp .serp-card__title { + margin-top: 6px; + color: var(--serp-title); + font-size: 20px; + line-height: 1.3; + white-space: nowrap; +} + +golem15-seo-serp .serp-card--mobile .serp-card__title { + white-space: normal; +} + +golem15-seo-serp .serp-card__description { + margin-top: 4px; + color: var(--serp-text); + font-size: 14px; + line-height: 1.58; +} + +golem15-seo-serp .serp-card__description--missing { + font-style: italic; +} + +golem15-seo-serp .seo-serp__notes { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 8px; +} + +golem15-seo-serp .seo-serp__error { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--c-danger); +} + +golem15-seo-serp .seo-serp__checks, +golem15-seo-serp .seo-serp__keyphrase { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 12px; + font-size: 13px; +} + +golem15-seo-serp .seo-serp__checking { + color: var(--c-muted); +} + +golem15-seo-serp .seo-serp__keyphrase-title { + font-size: 14px; + font-weight: 600; +} + +golem15-seo-serp .seo-serp__check { + display: inline-flex; + align-items: flex-start; + gap: 6px; +} + +golem15-seo-serp .seo-serp__check--ok { + color: var(--c-ok-text, var(--c-text)); +} + +golem15-seo-serp .seo-serp__check .seo-icon { + flex: none; + margin-top: 2px; +} + +golem15-seo-serp .seo-serp__link { + color: var(--c-text); + text-decoration: underline; +} + +golem15-seo-image .seo-image__preview { + margin-bottom: 8px; +} + +golem15-seo-image .seo-image__img { + display: block; + width: 240px; + height: 126px; + object-fit: cover; + border: 1px solid var(--c-border); + border-radius: 10px; + background: var(--c-subtle); +} + +golem15-seo-image .seo-image__actions { + display: flex; + gap: 8px; +} + +golem15-seo-image .seo-image__button { + display: inline-flex; + align-items: center; + height: 36px; + padding: 0 14px; + border: 1px solid var(--c-border-strong); + border-radius: 10px; + background: var(--c-surface); + color: var(--c-text); + font: inherit; + font-size: 14px; + cursor: pointer; +} + +golem15-seo-image .seo-image__status { + margin-top: 6px; + color: var(--c-muted); + font-size: 13px; +} + +golem15-seo-image .seo-image__status--error { + color: var(--c-danger); +} + +golem15-seo-image.seo-image--invalid .seo-image__img { + border-color: var(--c-danger); +} + +@media (max-width: 600px) { + golem15-seo-robots .seo-robots__panel { + grid-template-columns: 1fr; + } + golem15-seo-serp .serp-card, + golem15-seo-serp .serp-card--mobile { + max-width: none; + } +} + +@media (prefers-reduced-motion: reduce) { + golem15-seo-text .seo-text__control, + golem15-seo-robots .seo-robots__segment, + golem15-seo-serp .seo-serp__segment { + transition: none; + } +} diff --git a/assets/src/elements/text.ts b/assets/src/elements/text.ts new file mode 100644 index 0000000..6dfc506 --- /dev/null +++ b/assets/src/elements/text.ts @@ -0,0 +1,160 @@ +import { el, emit, fmt, icon, jsonAttr, labelsFor, num, uid, uiLocaleOf, type Labels } from './labels' +import { status, type Measure, type Status } from './measure' + +export const TEXT_TAG = 'golem15-seo-text' + +const STATUS_ICON: Record = { + empty: 'info', + short: 'triangle-alert', + long: 'triangle-alert', + good: 'circle-check', +} + +/** + * E1 golem15-seo-text (UI-SPEC C4): a text input (measure: title) or a + * textarea (measure: description) with a live character and pixel counter + * and a length status. The value is a plain string; the element emits + * summer-change with the right-trimmed value on every input. + */ +export class SeoTextElement extends HTMLElement { + static observedAttributes = ['options', 'disabled', 'invalid', 'ui-locale', 'locale'] + + private current = '' + private control: HTMLInputElement | HTMLTextAreaElement | null = null + private statusIcon: HTMLSpanElement | null = null + private statusText: HTMLSpanElement | null = null + private countText: HTMLSpanElement | null = null + private live: HTMLSpanElement | null = null + private lastStatus: Status | null = null + private builtFor = '' + + get value(): string { + return this.current + } + + set value(v: unknown) { + this.current = typeof v === 'string' ? v : v == null ? '' : String(v) + if (this.control && this.control.value !== this.current) { + this.control.value = this.current + } + this.update(false) + } + + connectedCallback(): void { + this.build() + } + + attributeChangedCallback(name: string): void { + if (!this.isConnected) { + return + } + if (name === 'options' || name === 'ui-locale') { + this.build() + return + } + this.sync() + } + + private measure(): Measure { + const options = jsonAttr>(this, 'options', {}) + return options.measure === 'description' ? 'description' : 'title' + } + + private labels(): Labels { + return labelsFor(uiLocaleOf(this)) + } + + private build(): void { + const key = this.measure() + '|' + uiLocaleOf(this) + if (this.builtFor === key && this.control) { + this.sync() + return + } + this.builtFor = key + this.replaceChildren() + const counterId = uid('seo-text-counter') + const measure = this.measure() + const control = measure === 'description' ? el('textarea', 'seo-text__control seo-text__control--area') : el('input', 'seo-text__control') + if (control instanceof HTMLTextAreaElement) { + control.rows = 3 + } else { + control.type = 'text' + } + control.value = this.current + control.setAttribute('aria-describedby', counterId) + const label = this.getAttribute('field-name') + if (label) { + control.setAttribute('aria-label', label) + } + control.addEventListener('input', () => { + if (this.hasAttribute('disabled')) { + return + } + this.current = control.value.replace(/\s+$/u, '') + this.update(true) + emit(this, 'summer-change', { value: this.current }) + }) + const counter = el('div', 'seo-text__counter') + counter.id = counterId + const start = el('span', 'seo-text__status') + this.statusIcon = el('span', 'seo-text__icon') + this.statusText = el('span', 'seo-text__status-text') + start.append(this.statusIcon, this.statusText) + this.countText = el('span', 'seo-text__count') + counter.append(start, this.countText) + this.live = el('span', 'seo-visually-hidden') + this.live.setAttribute('aria-live', 'polite') + this.live.setAttribute('role', 'status') + this.control = control + this.lastStatus = null + this.append(control, counter, this.live) + this.sync() + } + + private sync(): void { + if (!this.control) { + return + } + const disabled = this.hasAttribute('disabled') + this.control.readOnly = disabled + this.classList.toggle('seo-text--invalid', this.hasAttribute('invalid')) + this.control.setAttribute('aria-invalid', this.hasAttribute('invalid') ? 'true' : 'false') + this.update(false) + } + + private update(announce: boolean): void { + if (!this.statusText || !this.countText || !this.statusIcon) { + return + } + const labels = this.labels() + const locale = uiLocaleOf(this) + const measure = this.measure() + const result = status(this.current, measure) + const text = statusText(labels, result.status, measure) + this.statusIcon.replaceChildren(icon(STATUS_ICON[result.status])) + this.statusText.textContent = text + this.dataset.status = result.status + this.countText.textContent = fmt(labels.count, { + chars: num(result.chars, locale), + px: num(result.px, locale), + max: num(result.maxPx, locale), + }) + if (announce && this.live && result.status !== this.lastStatus) { + this.live.textContent = text + } + this.lastStatus = result.status + } +} + +function statusText(labels: Labels, s: Status, measure: Measure): string { + switch (s) { + case 'empty': + return measure === 'title' ? labels.emptyTitle : labels.emptyDescription + case 'short': + return measure === 'title' ? labels.shortTitle : labels.shortDescription + case 'long': + return measure === 'title' ? labels.longTitle : labels.longDescription + default: + return labels.good + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..4b5d4b8 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1774 @@ +{ + "name": "golem15-seo-elements", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "golem15-seo-elements", + "devDependencies": { + "happy-dom": "20.11.6", + "typescript": "5.9.3", + "vite": "7.3.5", + "vitest": "3.2.7" + }, + "engines": { + "node": ">=22.6" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.65.1.tgz", + "integrity": "sha512-5coxNSjqOI2J3NkVhlp3u+zGcyjnowQFsU9sg7MMz4UPXY4Dv63kFGLt7ConXz9CNq2i0yuOJZAF/2RMrgI2OA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.65.1.tgz", + "integrity": "sha512-K0HFf6XMB3IYs+jDZ1/RRhZaVOwxNXrl0n4MDEyGDjuK7+B6K/nDJp8DCBitO3S1sK/fzNxAf9QZAny4AIUPtg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.65.1.tgz", + "integrity": "sha512-zkZrXYwNlIcSUmcwY2DpsaDHr0XjVUeQHCuSunG3eQD7/5Wn/DvI7OMUE8NRRweK87yG8ebJ1xGOzmwpMsbZQA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.65.1.tgz", + "integrity": "sha512-Zaed+R3cpkkA3aiNd7NO0GV5KFh/LVf8j6bGcoRdpoXkSm/mWH8M0oX2/3u/nyGcx/bHEovD9CzX+w9/gcpI4A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.65.1.tgz", + "integrity": "sha512-h12p3WJWhyJ9kir0zqAPr0rKNmBzOWIjoaOMT9OULs9KZQZCeOMKd+mGSvERjd9VxJYShd3zIxtrVxkYz6BEVA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.65.1.tgz", + "integrity": "sha512-yRBwh/5imKY4DydDulVtrjy8Jf7h7NQJUqZbgmZ3zFsCD9QqlnWvZ7RtEF+ecO1b+IukKjXlu5Bb9fehV3tIbg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.65.1.tgz", + "integrity": "sha512-1XuPiWYYaLE1UDOqhbKVOfsqxvfmFS/lYPSk92U1hEX1qRfSYH0hNeAlbxFIgjp4d6+YQmL95EPRgdvjbZEddA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.65.1.tgz", + "integrity": "sha512-Y3C5e55Yrf6p2r5rBcc4P9hFWVNi6oqWy/53I/AyCJJGlp3KRSpTP8eoUmVntchTbEnhgBgAgLO9flj9AvOdUw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.65.1.tgz", + "integrity": "sha512-Itj6coSswkYPIcVLczObXmwdplHNQQxyfnK1ppBg4czDaX8RiEzLKqU7EMc64BiBuiDZEUS+M93ktM18rv5llw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.65.1.tgz", + "integrity": "sha512-KeO8jTEqdrnhvcahufbimyMoI44vedRUp1bVz2QUnRX2vDm/hxzCPt5F8fERVD5fsHyTW/vXmCElT054VOSVOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.65.1.tgz", + "integrity": "sha512-dDOz5VZZ+ZGnglpmET6VJefamWXFsgaVtuhSClf2/URZBp048jprD6z1hddsIMN6n1w+oUzGZEBp9u6Mlszsfg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.65.1.tgz", + "integrity": "sha512-JbfRKz8nJCuo8gn1Dqx8lQWEHw4qhZ3uRaHphw5vrSjnSptJTKTRC/bLKHh8QQMsEnhL4uq+bdg3tkiXbJ2q/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.65.1.tgz", + "integrity": "sha512-3oQpe1q0emSeCAssVHMQdOz5fz3PBH5my/Fr0ZekV2UMNL4ZiRa1ciikYlGQXyDkeqseYYv1InwOY8YPqY7RIQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.65.1.tgz", + "integrity": "sha512-nNV3HHi23o1LtpGGwH+Bav4hqnc4+klQtC96tSvCfvql8bLlNFTnTFiGl/jai2nve3eEr/vT2ocuyf+h+eMnYQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.65.1.tgz", + "integrity": "sha512-b5zab0FryBxuocuDeqOsYGAg2EzjKw4mKA6LjQXCyewXjGv/BXqACBsY2cO45xM4hV2weYtdjGThP2VB2ByAYg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.65.1.tgz", + "integrity": "sha512-Rh08xZakiUxKNgI3hbgH9mivlx478lSqckS9s+Oc3/TX1VrAH3W8z/5wpJXR/v5VjqcJ/jJNVBg64PU1+iQsTQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.65.1.tgz", + "integrity": "sha512-qDgtS6juN3/k97y4kcD6XLFpXEo4f6CnG8lTOGYwlhrUYTrMyhN0WHdZCPAW5rqS0MqQG7qI7dxnjugt6QB39Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.65.1.tgz", + "integrity": "sha512-44IKzBPqXXVm0kt1O+F5eW4juR5FyIxGNlFkpP8qwIDzucBsjM1HCbdYUQrQw8rU/g6HRl/tfBm0+Qq4H4Ro3w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.65.1.tgz", + "integrity": "sha512-tvYWZ/h2PCHs558Xu7B6LptuRCV8lXV2xI75ONMcldII1m2zlFwuI4BJZi9M6rSMkYqcPLLtPUPrjyqfbzLOdg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.65.1.tgz", + "integrity": "sha512-gdyeucgZCnei3pXwadKVTEAOgRx0B+c847DUPR/PdOMmp3olhfkYo6r0J4PzRFZB5ZICZQTxntyvgmWH5xT7rg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.65.1.tgz", + "integrity": "sha512-Wf/NEKdn2SAL4Nvt8HyDtdkseWTwiLbZEOoNYnEaHkbso0B9eHO42vKnZh+OYsn+IMhAMcWcuIwPlBOgOXsUzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.65.1.tgz", + "integrity": "sha512-x9VSsDmsMsEWy4cr/mFeaEsXnVJ6Kc4NCAU/lCnwml3s7LQbZoa9+/qwc9etWHbuuDT5mYxdyyKUwo76njtjZw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.65.1.tgz", + "integrity": "sha512-RIDbiVvvLkN45mOFpbo6eYMgnuiBEB62BGR/0s4QJgGg2AvVxucDDNn67kpAcUoYyfmY8iX8/RifMcmxmmnejA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.65.1.tgz", + "integrity": "sha512-+KN8HIr88vN59JNxlrM2fMimzY43Y0lQB7+0xdVsFbvFCAA74CLKCdfzIHzzvndJryBc94Pm+MvwtukKjaS2ug==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.65.1.tgz", + "integrity": "sha512-+Y1Sd49cSDq60UOl8/U2HI0ebUvnSl/hDhMHQn+ouw17yb8fYwMWIazzJ4JFpSkjPE7sj5NTNSX9IoyEqcFfyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.6.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz", + "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/buffer-image-size": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", + "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/happy-dom": { + "version": "20.11.6", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.11.6.tgz", + "integrity": "sha512-Hldbg8AdAa5a5oDcZpjqnGitp7JB0hqWmfv/8qr+kft4vzSD8BHsbdRfzYvL/0QcbKcURC/yyoygbeDQarPvYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "buffer-image-size": "^0.6.4", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.65.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.65.1.tgz", + "integrity": "sha512-HzD4IdphaPMvuAacnL/vtoq5B8FwqewKvxTtqYibWvP6VDyZ+UsTtlw5F8HIOIIpLQAkN2NOP/0sM3puo9xN/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.65.1", + "@rollup/rollup-android-arm64": "4.65.1", + "@rollup/rollup-darwin-arm64": "4.65.1", + "@rollup/rollup-darwin-x64": "4.65.1", + "@rollup/rollup-freebsd-arm64": "4.65.1", + "@rollup/rollup-freebsd-x64": "4.65.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.65.1", + "@rollup/rollup-linux-arm-musleabihf": "4.65.1", + "@rollup/rollup-linux-arm64-gnu": "4.65.1", + "@rollup/rollup-linux-arm64-musl": "4.65.1", + "@rollup/rollup-linux-loong64-gnu": "4.65.1", + "@rollup/rollup-linux-loong64-musl": "4.65.1", + "@rollup/rollup-linux-ppc64-gnu": "4.65.1", + "@rollup/rollup-linux-ppc64-musl": "4.65.1", + "@rollup/rollup-linux-riscv64-gnu": "4.65.1", + "@rollup/rollup-linux-riscv64-musl": "4.65.1", + "@rollup/rollup-linux-s390x-gnu": "4.65.1", + "@rollup/rollup-linux-x64-gnu": "4.65.1", + "@rollup/rollup-linux-x64-musl": "4.65.1", + "@rollup/rollup-openbsd-x64": "4.65.1", + "@rollup/rollup-openharmony-arm64": "4.65.1", + "@rollup/rollup-win32-arm64-msvc": "4.65.1", + "@rollup/rollup-win32-ia32-msvc": "4.65.1", + "@rollup/rollup-win32-x64-gnu": "4.65.1", + "@rollup/rollup-win32-x64-msvc": "4.65.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz", + "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz", + "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..bfd82cb --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "golem15-seo-elements", + "private": true, + "type": "module", + "engines": { + "node": ">=22.6" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "build": "tsc --noEmit && vite build", + "test": "vitest run --passWithNoTests" + }, + "devDependencies": { + "happy-dom": "20.11.6", + "typescript": "5.9.3", + "vite": "7.3.5", + "vitest": "3.2.7" + } +} diff --git a/scripts/check-dist.sh b/scripts/check-dist.sh new file mode 100755 index 0000000..bafc164 --- /dev/null +++ b/scripts/check-dist.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Drift gate for the committed SEO element bundle. Rebuilds the elements +# from the committed lockfile into a temporary directory and fails when the +# result differs from assets/dist, the files field_types.go embeds. After +# changing assets/src, rebuild with `npm run build` and commit assets/dist. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +if [[ ! -d node_modules ]]; then + npm ci --ignore-scripts +fi + +npm run typecheck + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +node_modules/.bin/vite build --outDir "$TMP/dist" --emptyOutDir --logLevel warn + +if ! diff -r "$TMP/dist" assets/dist; then + echo "check-dist: assets/dist is stale; run npm run build and commit assets/dist" >&2 + exit 1 +fi +echo "check-dist: assets/dist matches a fresh build" diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2485187 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "noEmit": true, + "isolatedModules": true, + "skipLibCheck": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "types": ["vite/client"] + }, + "include": ["assets/src", "tests", "vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..3719a88 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,35 @@ +import { defineConfig } from 'vitest/config' + +// Build of the SEO field elements (E1 golem15-seo-text, E2 +// golem15-seo-robots, E3 golem15-seo-serp, E7 golem15-seo-image): one ES +// module entry, assets/src/elements/index.ts, loaded by the admin SPA as a +// module script through the SEO field types' Scripts. Fixed output names +// with no content hash (cabana appends ?v= itself), built +// through the application pipeline so the output is minified. The bundle +// and its stylesheet are committed under assets/dist and embedded through +// field_types.go; scripts/check-dist.sh fails when they are stale. +export default defineConfig({ + build: { + outDir: 'assets/dist', + emptyOutDir: true, + target: 'es2022', + modulePreload: false, + copyPublicDir: false, + reportCompressedSize: true, + rollupOptions: { + input: 'assets/src/elements/index.ts', + preserveEntrySignatures: 'exports-only', + output: { + format: 'es', + entryFileNames: 'seo.js', + chunkFileNames: 'seo-[name].js', + assetFileNames: 'seo[extname]', + inlineDynamicImports: true, + }, + }, + }, + test: { + environment: 'happy-dom', + include: ['tests/**/*.test.ts'], + }, +})