diff --git a/admin_navigation.go b/admin_navigation.go index f148f2e..62d04b0 100644 --- a/admin_navigation.go +++ b/admin_navigation.go @@ -38,6 +38,13 @@ func (*Plugin) Navigation() []pact.NavigationItem { Permissions: []string{controllers.PermissionManage}, Controller: controllers.RulesControllerID, }, + { + Code: "robots", + Label: "golem15.seo::lang.nav.robots", + Icon: "file-text", + Permissions: []string{controllers.PermissionManage}, + Controller: controllers.RobotsControllerID, + }, { Code: "history", Label: "golem15.seo::lang.nav.history", diff --git a/assets/dist/seo.css b/assets/dist/seo.css index ffa3a5b..8cf8bf2 100644 --- a/assets/dist/seo.css +++ b/assets/dist/seo.css @@ -1 +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}} +golem15-seo-text,golem15-seo-robots,golem15-seo-serp,golem15-seo-image,golem15-seo-robotstxt,golem15-seo-robotstester{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)}golem15-seo-robotstxt .seo-robotstxt__control{box-sizing:border-box;display:block;width:100%;min-height:320px;padding:12px 14px;border:1px solid var(--c-border-strong);border-radius:10px;background:var(--c-surface);color:var(--c-text);font-family:DM Mono,ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px;line-height:1.6;white-space:pre;overflow-x:auto;resize:vertical;transition:border-color .15s,box-shadow .15s}golem15-seo-robotstxt .seo-robotstxt__control:focus{outline:none;border-color:var(--c-primary);box-shadow:0 0 0 3px var(--c-ring)}golem15-seo-robotstxt.seo-robotstxt--invalid .seo-robotstxt__control,golem15-seo-robotstxt.seo-robotstxt--over .seo-robotstxt__control{border-color:var(--c-danger)}golem15-seo-robotstxt .seo-robotstxt__lint{margin-top:8px;padding:12px 14px;border:1px solid var(--c-border);border-radius:10px;background:var(--c-subtle);font-size:13px}golem15-seo-robotstxt .seo-robotstxt__finding{display:flex;align-items:flex-start;gap:6px;overflow-wrap:anywhere}golem15-seo-robotstxt .seo-robotstxt__finding+.seo-robotstxt__finding{margin-top:4px}golem15-seo-robotstxt .seo-robotstxt__finding .seo-icon{flex:none;margin-top:3px}golem15-seo-robotstxt .seo-robotstxt__finding--clean{color:var(--c-ok-text, var(--c-text))}golem15-seo-robotstxt .seo-robotstxt__more{margin-top:4px;color:var(--c-muted)}golem15-seo-robotstxt .seo-robotstxt__size{display:flex;justify-content:flex-end;gap:12px;margin-top:6px;color:var(--c-muted);font-size:13px;font-variant-numeric:tabular-nums}golem15-seo-robotstxt .seo-robotstxt__over{color:var(--c-danger)}golem15-seo-robotstester .seo-robotstester__row{display:flex;flex-wrap:wrap;align-items:flex-end;gap:8px}golem15-seo-robotstester .seo-robotstester__field{display:flex;flex-direction:column;gap:4px}golem15-seo-robotstester .seo-robotstester__field--url{flex:1 1 240px}golem15-seo-robotstester .seo-robotstester__field--agent{min-width:200px}golem15-seo-robotstester .seo-robotstester__label{color:var(--c-muted);font-size:13px}golem15-seo-robotstester .seo-robotstester__input,golem15-seo-robotstester .seo-robotstester__select{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;font-size:14px;transition:border-color .15s,box-shadow .15s}golem15-seo-robotstester .seo-robotstester__input:focus,golem15-seo-robotstester .seo-robotstester__select:focus{outline:none;border-color:var(--c-primary);box-shadow:0 0 0 3px var(--c-ring)}golem15-seo-robotstester .seo-robotstester__button{height:42px;padding:0 16px;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-robotstester .seo-robotstester__button:focus-visible{outline:none;box-shadow:0 0 0 3px var(--c-ring)}golem15-seo-robotstester .seo-robotstester__button:disabled{cursor:default;opacity:.6}golem15-seo-robotstester .seo-robotstester__result{display:flex;align-items:flex-start;gap:6px;margin-top:10px;font-size:14px;overflow-wrap:anywhere}golem15-seo-robotstester .seo-robotstester__result .seo-icon{flex:none;margin-top:3px}golem15-seo-robotstester .seo-robotstester__result--allowed{color:var(--c-ok-text, var(--c-text))}golem15-seo-robotstester .seo-robotstester__result--blocked{color:var(--c-text)}golem15-seo-robotstester .seo-robotstester__result--error{color:var(--c-danger)}golem15-seo-robotstester .seo-robotstester__note{margin:6px 0 0;color:var(--c-muted);font-size:13px}@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-robotstxt .seo-robotstxt__control,golem15-seo-robotstester .seo-robotstester__input,golem15-seo-robotstester .seo-robotstester__select,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 index 419c7bf..572243d 100644 --- a/assets/dist/seo.js +++ b/assets/dist/seo.js @@ -1 +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}; +const H={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",size:":size of 500 KB",clean:"No problems found.",unknown:"Line :line: “:word” is not a robots.txt directive.",unknownSuggest:"Line :line: “:word” is not a robots.txt directive. Did you mean “:suggestion”?",noGroup:"Line :line: this rule has no User-agent line above it, so crawlers ignore it.",badPath:"Line :line: a path must start with / or *.",noColon:"Line :line: write the directive as “Name: value”.",ownSitemap:"Line :line: Sitemap lines are added automatically. This one is kept as written.",crawlDelay:"Line :line: Google ignores Crawl-delay. Bing and Yandex use it.",moreFindings:"and :count more",tooLarge:"Over 500 KB: the file cannot be saved.",url:"Address",urlPlaceholder:"/konto/ustawienia",agent:"Crawler",allowedRule:"Allowed for :agent. Rule: line :line “:rule”.",allowedNone:"Allowed for :agent: no rule matches this address.",blocked:"Blocked for :agent by line :line “:rule”.",blockedBot:"Blocked for :agent by its switch on the AI bots tab.",unsaved:"Tests the text in the editor and the AI bot switches, including unsaved changes.",stagingNote:"On this environment every address is blocked. The result shows the production behaviour.",urlRequired:"Enter an address starting with /."},Y={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",size:":size z 500 KB",clean:"Nie znaleziono problemów.",unknown:"Wiersz :line: „:word” nie jest dyrektywą robots.txt.",unknownSuggest:"Wiersz :line: „:word” nie jest dyrektywą robots.txt. Czy chodziło o „:suggestion”?",noGroup:"Wiersz :line: nad tą regułą nie ma wiersza User-agent, więc roboty ją ignorują.",badPath:"Wiersz :line: ścieżka musi zaczynać się od / lub *.",noColon:"Wiersz :line: zapisz dyrektywę jako „Nazwa: wartość”.",ownSitemap:"Wiersz :line: wiersze Sitemap są dodawane automatycznie. Ten zostaje bez zmian.",crawlDelay:"Wiersz :line: Google ignoruje Crawl-delay. Bing i Yandex go stosują.",moreFindings:"i :count więcej",tooLarge:"Ponad 500 KB: nie można zapisać pliku.",url:"Adres",urlPlaceholder:"/konto/ustawienia",agent:"Robot",allowedRule:"Dozwolone dla :agent. Reguła: wiersz :line „:rule”.",allowedNone:"Dozwolone dla :agent: żadna reguła nie pasuje do tego adresu.",blocked:"Zablokowane dla :agent przez wiersz :line „:rule”.",blockedBot:"Zablokowane dla :agent przełącznikiem w zakładce Boty AI.",unsaved:"Sprawdza tekst w edytorze i przełączniki botów AI, także niezapisane zmiany.",stagingNote:"W tym środowisku każdy adres jest zablokowany. Wynik pokazuje zachowanie na produkcji.",urlRequired:"Wpisz adres zaczynający się od /."},J={en:H,pl:Y};function k(n){return(n.getAttribute("ui-locale")||document.documentElement.lang||"en").toLowerCase().split(/[-_]/)[0]||"en"}function C(n){return J[n]??H}function g(n,e={}){const t=Object.keys(e).sort((i,o)=>o.length-i.length);let s=n;for(const i of t)s=s.split(":"+i).join(String(e[i]));return s}function q(n,e,t,s={}){let i="other";try{i=new Intl.PluralRules(t).select(e)}catch{i=e===1?"one":"other"}const o=i==="one"&&n.one||i==="few"&&n.few||i==="many"&&n.many||n.other;return g(o,{...s,count:new Intl.NumberFormat(t).format(e)})}function I(n,e){try{return new Intl.NumberFormat(e).format(n)}catch{return String(n)}}const O="http://www.w3.org/2000/svg",Q={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 z(n,e=14){const t=document.createElementNS(O,"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--"+n);for(const s of Q[n]??[]){const i=document.createElementNS(O,"path");i.setAttribute("d",s),t.appendChild(i)}return t}function r(n,e="",t){const s=document.createElement(n);return e&&(s.className=e),t!==void 0&&(s.textContent=t),s}function D(n,e,t){const s=n.getAttribute(e);if(s===null||s==="")return t;try{return JSON.parse(s)}catch{return t}}function T(n,e,t){n.dispatchEvent(new CustomEvent(e,{detail:t,bubbles:!0,composed:!0}))}let U=0;function S(n){return U+=1,`${n}-${U}`}const ee="golem15-seo-image";function te(n){return/^\/(?!\/)/.test(n)||/^https?:\/\//i.test(n)}class se 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="",T(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 C(k(this))}render(){const e=this.labels(),t=this.hasAttribute("disabled"),s=[];if(this.current&&te(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),s.push(o)}if(!t){const o=r("div","seo-image__actions"),a=S("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=S("seo-image-upload"),this.error="",this.render(),T(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="",T(this,"summer-change",{value:""}),this.render()}),o.append(u)}s.push(o)}const i=r("div","seo-image__status"+(this.error?" seo-image__status--error":""));i.setAttribute("role","status"),i.textContent=this.pending?e.imageUploading:this.error,s.push(i),this.classList.toggle("seo-image--invalid",this.hasAttribute("invalid")),this.replaceChildren(...s)}}const ne="golem15-seo-robots";function P(){return{index:"",follow:"",noarchive:!1,nosnippet:!1,noimageindex:!1,maxSnippet:null,maxImagePreview:"",maxVideoPreview:null,unavailableAfter:""}}function ie(n){const e=P(),t=n.trim();if(t==="")return e;const s=new Set;for(const i of t.split(",")){const o=i.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(s.has(u))return null;switch(s.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(n){const e=[];return n.index&&e.push(n.index),n.follow&&e.push(n.follow),n.noarchive&&e.push("noarchive"),n.nosnippet&&e.push("nosnippet"),n.noimageindex&&e.push("noimageindex"),n.maxSnippet!==null&&e.push("max-snippet:"+n.maxSnippet),n.maxImagePreview&&e.push("max-image-preview:"+n.maxImagePreview),n.maxVideoPreview!==null&&e.push("max-video-preview:"+n.maxVideoPreview),n.unavailableAfter&&e.push("unavailable_after:"+n.unavailableAfter),e.join(",")}function R(n){return[n.noarchive,n.nosnippet,n.noimageindex,n.maxSnippet!==null,n.maxImagePreview!=="",n.maxVideoPreview!==null,n.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=ie(t)??P(),this.open=this.open||R(this.model)>0,this.render())}connectedCallback(){this.open=R(this.model)>0,this.render()}attributeChangedCallback(){this.isConnected&&this.render()}labels(){return C(k(this))}commit(){this.current=oe(this.model),T(this,"summer-change",{value:this.current}),this.render()}render(){const e=this.labels(),t=this.hasAttribute("disabled"),i=D(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",[["",i,"inherit"],["index",e.index,"ok"],["noindex",e.noindex,"danger"]],t),this.radioGroup(e.links,"follow",[["",i,"inherit"],["follow",e.follow,"ok"],["nofollow",e.nofollow,"danger"]],t));const l=S("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),z("chevron-down")),c.addEventListener("click",()=>{this.open=!this.open,this.render()});const u=r("div","seo-robots__advanced-row");u.append(c);const d=R(this.model);d>0&&u.append(r("span","seo-robots__summary",q(e.advancedSet,d,k(this))));const p=r("div","seo-robots__panel");p.id=l,p.hidden=!this.open,p.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 h=r("label","seo-robots__check"),m=r("input");m.type="checkbox",m.checked=this.model[b],m.disabled=t,m.dataset.focusKey=b,m.addEventListener("change",()=>{this.model[b]=m.checked,this.commit()}),h.append(m,r("span","",e[b])),f.append(h)}p.append(f),this.replaceChildren(a,u,p),o&&this.querySelector(`[data-focus-key="${o}"]`)?.focus()}radioGroup(e,t,s,i){const o=r("div","seo-robots__group"),a=S("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 s.forEach(([d,p,f],b)=>{const h=r("button","seo-robots__segment seo-robots__segment--"+f,p);h.type="button",h.setAttribute("role","radio");const m=u===d;h.setAttribute("aria-checked",m?"true":"false"),h.tabIndex=m?0:-1,h.disabled=i,h.dataset.focusKey=t+"-"+b,h.addEventListener("click",()=>this.choose(t,d)),h.addEventListener("keydown",v=>{const _=v.key==="ArrowRight"||v.key==="ArrowDown"?1:v.key==="ArrowLeft"||v.key==="ArrowUp"?-1:0;if(_===0||i)return;v.preventDefault();const w=(b+_+s.length)%s.length;this.choose(t,s[w][0]),this.querySelector(`[data-focus-key="${t}-${w}"]`)?.focus()}),c.append(h)}),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,s,i,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=i,l.dataset.focusKey=s;const c=this.model[s];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[s]=null,this.commit();return}if(!/^-?\d+$/.test(d)||Number.parseInt(d,10)<-1){u.textContent=o.invalidNumber;return}this.model[s]=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 s=r("label","seo-robots__field");s.append(r("span","seo-robots__label",e.maxImagePreview));const i=r("select","seo-robots__input");i.disabled=t,i.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,i.append(l)}return i.addEventListener("change",()=>{this.model.maxImagePreview=i.value,this.commit()}),s.append(i),s}dateField(e,t){const s=r("label","seo-robots__field");s.append(r("span","seo-robots__label",e.unavailableAfter));const i=r("input","seo-robots__input");return i.type="date",i.disabled=t,i.dataset.focusKey="unavailableAfter",i.value=this.model.unavailableAfter,i.addEventListener("change",()=>{this.model.unavailableAfter=/^\d{4}-\d{2}-\d{2}$/.test(i.value)?i.value:"",this.commit()}),s.append(i),s}}const ae="golem15-seo-robotstester",B=["Googlebot","Googlebot-Image","Bingbot","*"];function le(n,e){const t={agent:e.agent,line:e.line,rule:e.rule};return e.allowed?e.line>0?g(n.allowedRule,t):g(n.allowedNone,t):e.bot?g(n.blockedBot,t):g(n.blocked,t)}class ce extends HTMLElement{static observedAttributes=["busy","state","ui-locale","disabled","label","busy-label"];context=null;result=null;error="";inflight=null;queued=null;connected=!1;input=null;select=null;button=null;line=null;notes=null;builtFor="";onResult=e=>{const s=e.detail?.data;this.inflight=null,s&&s.op==="context"?(this.context=s,this.build(!0)):s&&s.op==="test"&&(this.result=s,this.error="",this.renderResult()),this.flush()};connectedCallback(){this.connected=!0,this.addEventListener("summer-result",this.onResult),this.build(!1),this.request({op:"context"})}disconnectedCallback(){this.connected=!1,this.removeEventListener("summer-result",this.onResult)}attributeChangedCallback(e,t,s){if(this.connected){if(e==="busy"&&s===null&&this.flush(),e==="state"&&s==="error"&&(this.inflight=null),e==="ui-locale"){this.build(!0);return}this.syncControls()}}labels(){return C(k(this))}request(e){if(this.hasAttribute("busy")||this.inflight){this.queued=e;return}this.inflight=e,T(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)}build(e){const t=k(this)+"|"+(this.context?.agents??B).join(",");if(!e&&this.builtFor===t&&this.input){this.syncControls();return}this.builtFor=t;const s=this.labels(),i=this.input?.value??"",o=this.select?.value??"Googlebot";this.replaceChildren();const a=r("div","seo-robotstester__row"),l=S("seo-robotstester-url"),c=r("div","seo-robotstester__field seo-robotstester__field--url"),u=r("label","seo-robotstester__label",s.url);u.htmlFor=l;const d=r("input","seo-robotstester__input");d.id=l,d.type="text",d.placeholder=s.urlPlaceholder,d.value=i,d.addEventListener("keydown",w=>{w.key==="Enter"&&(w.preventDefault(),this.test())}),c.append(u,d);const p=S("seo-robotstester-agent"),f=r("div","seo-robotstester__field seo-robotstester__field--agent"),b=r("label","seo-robotstester__label",s.agent);b.htmlFor=p;const h=r("select","seo-robotstester__select");h.id=p;for(const w of this.context?.agents??B){const L=r("option","",w);L.value=w,h.append(L)}h.value=o,h.value!==o&&(h.value="Googlebot"),f.append(b,h);const m=r("button","seo-robotstester__button");m.type="button",m.addEventListener("click",()=>this.test()),a.append(c,f,m);const v=r("div","seo-robotstester__result");v.setAttribute("role","status");const _=r("div","seo-robotstester__notes");this.input=d,this.select=h,this.button=m,this.line=v,this.notes=_,this.append(a,v,_),this.syncControls(),this.renderResult()}syncControls(){if(!this.button||!this.input||!this.select)return;const e=this.hasAttribute("disabled"),t=this.hasAttribute("busy");this.input.readOnly=e,this.select.disabled=e,this.button.hidden=e,this.button.disabled=t,this.button.textContent=t?this.getAttribute("busy-label")||this.getAttribute("label")||"":this.getAttribute("label")||""}test(){if(this.hasAttribute("disabled")||!this.input||!this.select)return;const e=this.input.value.trim();if(!e.startsWith("/")){this.error=this.labels().urlRequired,this.result=null,this.renderResult();return}this.error="",this.request({op:"test",url:e,agent:this.select.value})}renderResult(){if(!this.line||!this.notes)return;const e=this.labels();if(this.line.className="seo-robotstester__result",this.error)this.line.classList.add("seo-robotstester__result--error"),this.line.replaceChildren(r("span","",this.error));else if(this.result){const s=this.result.allowed;this.line.classList.add(s?"seo-robotstester__result--allowed":"seo-robotstester__result--blocked"),this.line.replaceChildren(z(s?"circle-check":"triangle-alert"),r("span","",le(e,this.result)))}else this.line.replaceChildren();const t=[];(this.context?.staging||D(this,"data",{}).staging)&&t.push(r("p","seo-robotstester__note",e.stagingNote)),t.push(r("p","seo-robotstester__note",e.unsaved)),this.notes.replaceChildren(...t)}}const ue="golem15-seo-robotstxt",de=500*1024,j=10,he=["User-agent","Allow","Disallow","Sitemap","Crawl-delay","Host","Clean-param"];function pe(n,e){const t=Array.from({length:e.length+1},(s,i)=>i);for(let s=1;s<=n.length;s++){let i=t[0];t[0]=s;for(let o=1;o<=e.length;o++){const a=t[o];t[o]=Math.min(t[o]+1,t[o-1]+1,i+(n[s-1]===e[o-1]?0:1)),i=a}}return t[e.length]}function me(n){const e=n.toLowerCase();let t="",s=3;for(const i of he){const o=pe(e,i.toLowerCase());o{const o=i+1,a=s.indexOf("#"),l=(a>=0?s.slice(0,a):s).trim();if(l==="")return;const c=l.indexOf(":");if(c<0){e.push({kind:"noColon",line:o});return}const u=l.slice(0,c).trim(),d=l.slice(c+1).trim();switch(u.toLowerCase()){case"user-agent":t=!0;return;case"allow":case"disallow":t?d!==""&&!d.startsWith("/")&&!d.startsWith("*")&&e.push({kind:"badPath",line:o}):e.push({kind:"noGroup",line:o});return;case"sitemap":e.push({kind:"ownSitemap",line:o});return;case"crawl-delay":e.push({kind:"crawlDelay",line:o});return;case"host":case"clean-param":return}const p=me(u);e.push(p?{kind:"unknownSuggest",line:o,word:u,suggestion:p}:{kind:"unknown",line:o,word:u})}),e}function be(n){return new TextEncoder().encode(n).length}function ge(n,e){return g(n[e.kind],{line:e.line,word:e.word??"",suggestion:e.suggestion??""})}class ve extends HTMLElement{static observedAttributes=["disabled","invalid","ui-locale"];current="";control=null;panel=null;meter=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()}connectedCallback(){this.build()}attributeChangedCallback(e){if(this.isConnected){if(e==="ui-locale"){this.build();return}this.sync()}}labels(){return C(k(this))}build(){const e=k(this);if(this.builtFor===e&&this.control){this.sync();return}this.builtFor=e,this.replaceChildren();const t=S("seo-robotstxt-lint"),s=r("textarea","seo-robotstxt__control");s.spellcheck=!1,s.setAttribute("wrap","off"),s.value=this.current,s.setAttribute("aria-describedby",t);const i=this.getAttribute("field-name");i&&s.setAttribute("aria-label",i),s.addEventListener("input",()=>{this.hasAttribute("disabled")||(this.current=s.value,this.update(),T(this,"summer-change",{value:this.current}))});const o=r("div","seo-robotstxt__lint");o.id=t,o.setAttribute("role","status");const a=r("div","seo-robotstxt__size");this.control=s,this.panel=o,this.meter=a,this.append(s,o,a),this.sync()}sync(){this.control&&(this.control.readOnly=this.hasAttribute("disabled"),this.classList.toggle("seo-robotstxt--invalid",this.hasAttribute("invalid")),this.control.setAttribute("aria-invalid",this.hasAttribute("invalid")?"true":"false"),this.update())}update(){if(!this.panel||!this.meter)return;const e=this.labels(),t=k(this),s=fe(this.current),i=[];if(s.length===0){const c=r("div","seo-robotstxt__finding seo-robotstxt__finding--clean");c.append(z("circle-check"),r("span","",e.clean)),i.push(c)}else{for(const c of s.slice(0,j)){const u=r("div","seo-robotstxt__finding");u.append(z("triangle-alert"),r("span","",ge(e,c))),i.push(u)}s.length>j&&i.push(r("div","seo-robotstxt__more",g(e.moreFindings,{count:I(s.length-j,t)})))}this.panel.replaceChildren(...i);const o=be(this.current),a=new Intl.NumberFormat(t,{maximumFractionDigits:1}).format(o/1024)+" KB";this.meter.replaceChildren(r("span","",g(e.size,{size:a})));const l=o>de;this.classList.toggle("seo-robotstxt--over",l),l&&this.meter.append(r("span","seo-robotstxt__over",e.tooLarge))}}const K="20px Arial, sans-serif",V="14px Arial, sans-serif",X=580,$=920,we=680,xe=30,ke=120;let N;function _e(){if(N!==void 0)return N;try{const e=document.createElement("canvas").getContext("2d");N=e&&typeof e.measureText=="function"?e:null}catch{N=null}return N}function ye(n,e){let t=0;for(const s of n)/[ilIj.,:;'!|]/.test(s)?t+=.28:/[mwMW@]/.test(s)?t+=.85:/[A-Z]/.test(s)?t+=.67:s===" "?t+=.28:t+=.53;return t*e}function M(n,e){const t=_e();if(t){t.font=e;const i=t.measureText(n).width;if(Number.isFinite(i)&&(i>0||n===""))return i}const s=Number.parseFloat(e)||14;return ye(n,s)}function W(n,e,t){if(M(n,e)<=t)return n;const s=Array.from(n);let i=0,o=s.length;for(;is?c="long":a=i}function E(n,e){const t=G(n);if(t.length===0)return!1;const s=G(e);for(let i=0;i+t.length<=s.length;i++){let o=!0;for(let a=0;a{const s=e.detail?.data;this.inflight=null,s&&s.op==="context"?(this.context=s,this.contextFailed=!1,this.render(),this.scheduleDuplicates(!0)):s&&s.op==="duplicates"&&(this.duplicates.set(s.locale,s),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,s){if(this.connected){if(e==="busy"&&s===null){this.flush();return}if(e==="state"&&s==="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 C(k(this))}request(e){if(this.hasAttribute("busy")||this.inflight){this.queued=e;return}this.inflight=e,T(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 D(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 s=this.fill(),i=y(s,"seo_title",t.locale)||y(s,"title",t.locale),o=y(s,"seo_description",t.locale)||y(s,"description",t.locale),a=JSON.stringify([t.locale,i,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:i,description:o})};e?l():this.timer=setTimeout(l,Se)}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 s=this.locale(),i=s?.locale??this.shownLocale,o=this.fill(),a=(y(o,"seo_title",i)||y(o,"title",i)).trim(),l=(y(o,"seo_description",i)||y(o,"description",i)).trim(),c=y(o,"seo_keyphrase",i).trim(),u=s?.siteName??"",d=a||Le(s?.titleTemplate??"",{title:s?.defaultTitle??"",site:u})||s?.defaultTitle||"",p=l||s?.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 x of["desktop","mobile"]){const A=r("button","seo-serp__segment",e[x]);A.type="button",A.setAttribute("role","radio"),A.setAttribute("aria-checked",this.device===x?"true":"false"),A.addEventListener("click",()=>{this.device=x,this.render()}),b.append(A)}f.append(b),i&&f.append(r("span","seo-serp__language",g(e.language,{locale:i.toUpperCase()})));const h=r("div","serp-card"+(this.device==="mobile"?" serp-card--mobile":"")),m=r("div","serp-card__site");m.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",De(this.context?.host??"",s?.url??null))),m.append(v),h.append(m),h.append(r("div","serp-card__title",W(d,K,X))),p){const x=this.device==="mobile"?we:$;h.append(r("div","serp-card__description",W(p,V,x)))}else h.append(r("div","serp-card__description serp-card__description--missing",e.noDescription));const _=r("div","seo-serp__notes");if(this.contextFailed){const x=r("div","seo-serp__error");x.setAttribute("role","status"),x.append(z("circle-alert"),r("span","",e.contextFailed)),_.append(x)}!a&&s?.titleTemplate&&_.append(r("div","",g(e.fromTemplate,{template:s.titleTemplate}))),!l&&s?.defaultDescription&&s.descriptionSource==="content"&&_.append(r("div","",e.fromContent)),s&&s.url===null&&_.append(r("div","",e.noUrl)),(this.getAttribute("record-id")??"")!==""&&s&&_.append(r("div","",e.savedNote));const w=r("div","seo-serp__checks");if(w.setAttribute("role","status"),this.checking)w.append(r("div","seo-serp__checking",e.checking));else{const x=this.duplicates.get(i);if(x)for(const[A,Z]of[[x.title,e.dupTitle],[x.description,e.dupDescription]])A.length>0&&w.append(Re(A,Z,e,t))}const L=[f,h,_,w];c&&s&&L.push(je(c,{title:d,description:p,h1:s.h1,url:s.url,bodyWords:s.bodyWords},e,t)),this.replaceChildren(...L)}}function De(n,e){const t=(e??"").split("?")[0].split("/").filter(Boolean).map(s=>{try{return decodeURIComponent(s)}catch{return s}});return[n,...t].filter(Boolean).join(" › ")}function Re(n,e,t,s){const i=r("div","seo-serp__check seo-serp__check--warn");i.append(z("triangle-alert"));const o=r("span"),a="\0list\0",l=q(e,n.length,s,{list:a}),[c,u]=l.split(a);return o.append(document.createTextNode(c??"")),n.slice(0,F).forEach((d,p)=>{if(p>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))}),n.length>F&&o.append(document.createTextNode(" "+g(t.andMore,{count:n.length-F}))),o.append(document.createTextNode(u??"")),i.append(o),i}function je(n,e,t,s){const i=r("div","seo-serp__keyphrase");i.append(r("div","seo-serp__keyphrase-title",g(t.keyphrase,{phrase:n})));const o=(a,l,c)=>{const u=r("div","seo-serp__check "+(a?"seo-serp__check--ok":"seo-serp__check--warn"));u.append(z(a?"circle-check":"triangle-alert"),r("span","",a?l:c)),i.append(u)};if(o(E(n,e.title),t.kpTitle,t.kpTitleNo),e.h1!==null&&o(E(n,e.h1),t.kpH1,t.kpH1No),o(E(n,e.description),t.kpDesc,t.kpDescNo),e.url!==null&&o(E(n,e.url.replace(/[-_/]+/g," ")),t.kpUrl,t.kpUrlNo),e.bodyWords!==null){const a=new Intl.NumberFormat(s).format(e.bodyWords);o(e.bodyWords>=Ce,g(t.kpLength,{count:a}),g(t.kpLengthNo,{count:a}))}return i}const Fe="golem15-seo-text",Pe={empty:"info",short:"triangle-alert",long:"triangle-alert",good:"circle-check"};class Me 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 D(this,"options",{}).measure==="description"?"description":"title"}labels(){return C(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=S("seo-text-counter"),i=this.measure()==="description"?r("textarea","seo-text__control seo-text__control--area"):r("input","seo-text__control");i instanceof HTMLTextAreaElement?i.rows=3:i.type="text",i.value=this.current,i.setAttribute("aria-describedby",t);const o=this.getAttribute("field-name");o&&i.setAttribute("aria-label",o),i.addEventListener("input",()=>{this.hasAttribute("disabled")||(this.current=i.value.replace(/\s+$/u,""),this.update(!0),T(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=i,this.lastStatus=null,this.append(i,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(),s=k(this),i=this.measure(),o=Te(this.current,i),a=Oe(t,o.status,i);this.statusIcon.replaceChildren(z(Pe[o.status])),this.statusText.textContent=a,this.dataset.status=o.status,this.countText.textContent=g(t.count,{chars:I(o.chars,s),px:I(o.px,s),max:I(o.maxPx,s)}),e&&this.live&&o.status!==this.lastStatus&&(this.live.textContent=a),this.lastStatus=o.status}}function Oe(n,e,t){switch(e){case"empty":return t==="title"?n.emptyTitle:n.emptyDescription;case"short":return t==="title"?n.shortTitle:n.shortDescription;case"long":return t==="title"?n.longTitle:n.longDescription;default:return n.good}}const Ue=[[Fe,Me],[ne,re],[ze,Ie],[ee,se],[ue,ve],[ae,ce]];for(const[n,e]of Ue)customElements.get(n)||customElements.define(n,e);export{ee as IMAGE_TAG,ae as ROBOTSTESTER_TAG,ue as ROBOTSTXT_TAG,ne as ROBOTS_TAG,ze as SERP_TAG,se as SeoImageElement,re as SeoRobotsElement,ce as SeoRobotsTesterElement,ve as SeoRobotsTxtElement,Ie as SeoSerpElement,Me as SeoTextElement,Fe as TEXT_TAG}; diff --git a/assets/src/elements/index.ts b/assets/src/elements/index.ts index ecb73de..25162b8 100644 --- a/assets/src/elements/index.ts +++ b/assets/src/elements/index.ts @@ -1,23 +1,39 @@ import './styles.css' import { IMAGE_TAG, SeoImageElement } from './image' import { ROBOTS_TAG, SeoRobotsElement } from './robots' +import { ROBOTSTESTER_TAG, SeoRobotsTesterElement } from './robotstester' +import { ROBOTSTXT_TAG, SeoRobotsTxtElement } from './robotstxt' 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) +const elements: Array<[string, CustomElementConstructor]> = [ + [TEXT_TAG, SeoTextElement], + [ROBOTS_TAG, SeoRobotsElement], + [SERP_TAG, SeoSerpElement], + [IMAGE_TAG, SeoImageElement], + [ROBOTSTXT_TAG, SeoRobotsTxtElement], + [ROBOTSTESTER_TAG, SeoRobotsTesterElement], +] +for (const [tag, ctor] of elements) { + if (!customElements.get(tag)) { + customElements.define(tag, ctor) + } } -export { IMAGE_TAG, ROBOTS_TAG, SERP_TAG, SeoImageElement, SeoRobotsElement, SeoSerpElement, SeoTextElement, TEXT_TAG } +export { + IMAGE_TAG, + ROBOTS_TAG, + ROBOTSTESTER_TAG, + ROBOTSTXT_TAG, + SERP_TAG, + SeoImageElement, + SeoRobotsElement, + SeoRobotsTesterElement, + SeoRobotsTxtElement, + SeoSerpElement, + SeoTextElement, + TEXT_TAG, +} diff --git a/assets/src/elements/labels.ts b/assets/src/elements/labels.ts index ef1b7f7..7d612ce 100644 --- a/assets/src/elements/labels.ts +++ b/assets/src/elements/labels.ts @@ -1,5 +1,6 @@ // 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 +// E4 and E5, 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. */ @@ -77,6 +78,29 @@ export interface Labels { imageUploading: string imageFailed: string imageAlt: string + // E4 + size: string + clean: string + unknown: string + unknownSuggest: string + noGroup: string + badPath: string + noColon: string + ownSitemap: string + crawlDelay: string + moreFindings: string + tooLarge: string + // E5 + url: string + urlPlaceholder: string + agent: string + allowedRule: string + allowedNone: string + blocked: string + blockedBot: string + unsaved: string + stagingNote: string + urlRequired: string } const en: Labels = { @@ -142,6 +166,27 @@ const en: Labels = { imageUploading: 'Uploading…', imageFailed: 'The image could not be uploaded.', imageAlt: 'Social image preview', + size: ':size of 500 KB', + clean: 'No problems found.', + unknown: 'Line :line: “:word” is not a robots.txt directive.', + unknownSuggest: 'Line :line: “:word” is not a robots.txt directive. Did you mean “:suggestion”?', + noGroup: 'Line :line: this rule has no User-agent line above it, so crawlers ignore it.', + badPath: 'Line :line: a path must start with / or *.', + noColon: 'Line :line: write the directive as “Name: value”.', + ownSitemap: 'Line :line: Sitemap lines are added automatically. This one is kept as written.', + crawlDelay: 'Line :line: Google ignores Crawl-delay. Bing and Yandex use it.', + moreFindings: 'and :count more', + tooLarge: 'Over 500 KB: the file cannot be saved.', + url: 'Address', + urlPlaceholder: '/konto/ustawienia', + agent: 'Crawler', + allowedRule: 'Allowed for :agent. Rule: line :line “:rule”.', + allowedNone: 'Allowed for :agent: no rule matches this address.', + blocked: 'Blocked for :agent by line :line “:rule”.', + blockedBot: 'Blocked for :agent by its switch on the AI bots tab.', + unsaved: 'Tests the text in the editor and the AI bot switches, including unsaved changes.', + stagingNote: 'On this environment every address is blocked. The result shows the production behaviour.', + urlRequired: 'Enter an address starting with /.', } const pl: Labels = { @@ -217,6 +262,27 @@ const pl: Labels = { imageUploading: 'Wgrywanie…', imageFailed: 'Nie udało się wgrać obrazu.', imageAlt: 'Podgląd obrazu w mediach społecznościowych', + size: ':size z 500 KB', + clean: 'Nie znaleziono problemów.', + unknown: 'Wiersz :line: „:word” nie jest dyrektywą robots.txt.', + unknownSuggest: 'Wiersz :line: „:word” nie jest dyrektywą robots.txt. Czy chodziło o „:suggestion”?', + noGroup: 'Wiersz :line: nad tą regułą nie ma wiersza User-agent, więc roboty ją ignorują.', + badPath: 'Wiersz :line: ścieżka musi zaczynać się od / lub *.', + noColon: 'Wiersz :line: zapisz dyrektywę jako „Nazwa: wartość”.', + ownSitemap: 'Wiersz :line: wiersze Sitemap są dodawane automatycznie. Ten zostaje bez zmian.', + crawlDelay: 'Wiersz :line: Google ignoruje Crawl-delay. Bing i Yandex go stosują.', + moreFindings: 'i :count więcej', + tooLarge: 'Ponad 500 KB: nie można zapisać pliku.', + url: 'Adres', + urlPlaceholder: '/konto/ustawienia', + agent: 'Robot', + allowedRule: 'Dozwolone dla :agent. Reguła: wiersz :line „:rule”.', + allowedNone: 'Dozwolone dla :agent: żadna reguła nie pasuje do tego adresu.', + blocked: 'Zablokowane dla :agent przez wiersz :line „:rule”.', + blockedBot: 'Zablokowane dla :agent przełącznikiem w zakładce Boty AI.', + unsaved: 'Sprawdza tekst w edytorze i przełączniki botów AI, także niezapisane zmiany.', + stagingNote: 'W tym środowisku każdy adres jest zablokowany. Wynik pokazuje zachowanie na produkcji.', + urlRequired: 'Wpisz adres zaczynający się od /.', } const dictionaries: Record = { en, pl } diff --git a/assets/src/elements/robotstester.ts b/assets/src/elements/robotstester.ts new file mode 100644 index 0000000..20ed148 --- /dev/null +++ b/assets/src/elements/robotstester.ts @@ -0,0 +1,244 @@ +import { el, emit, fmt, icon, jsonAttr, labelsFor, uid, uiLocaleOf, type Labels } from './labels' + +export const ROBOTSTESTER_TAG = 'golem15-seo-robotstester' + +/** The tester's context answer: the crawler list and the environment. */ +interface ContextData { + op: 'context' + agents: string[] + bots: string[] + staging: boolean +} + +/** The tester's test answer. */ +interface TestData { + op: 'test' + url: string + allowed: boolean + line: number + rule: string + bot: boolean + agent: string + staging: boolean +} + +type Payload = { op: 'context' } | { op: 'test'; url: string; agent: string } + +/** The crawler list before the context answer arrives. */ +const DEFAULT_AGENTS = ['Googlebot', 'Googlebot-Image', 'Bingbot', '*'] + +/** The result line of a test answer in the element's language. */ +export function resultText(labels: Labels, r: TestData): string { + const params = { agent: r.agent, line: r.line, rule: r.rule } + if (r.allowed) { + return r.line > 0 ? fmt(labels.allowedRule, params) : fmt(labels.allowedNone, params) + } + return r.bot ? fmt(labels.blockedBot, params) : fmt(labels.blocked, params) +} + +/** + * E5 golem15-seo-robotstester (UI-SPEC C10): an address input, a crawler + * select and the test button (labelled from the action), and the result + * line. It tests the editor's text and the AI bot switches the form holds, + * including unsaved changes (the widget's fill), through the robots_test + * action only. + */ +export class SeoRobotsTesterElement extends HTMLElement { + static observedAttributes = ['busy', 'state', 'ui-locale', 'disabled', 'label', 'busy-label'] + + private context: ContextData | null = null + private result: TestData | null = null + private error = '' + private inflight: Payload | null = null + private queued: Payload | null = null + private connected = false + private input: HTMLInputElement | null = null + private select: HTMLSelectElement | null = null + private button: HTMLButtonElement | null = null + private line: HTMLDivElement | null = null + private notes: HTMLDivElement | null = null + private builtFor = '' + + private readonly onResult = (ev: Event): void => { + const detail = (ev as CustomEvent).detail as { data?: unknown } | undefined + const data = detail?.data as ContextData | TestData | undefined + this.inflight = null + if (data && data.op === 'context') { + this.context = data + this.build(true) + } else if (data && data.op === 'test') { + this.result = data + this.error = '' + this.renderResult() + } + this.flush() + } + + connectedCallback(): void { + this.connected = true + this.addEventListener('summer-result', this.onResult) + this.build(false) + this.request({ op: 'context' }) + } + + disconnectedCallback(): void { + this.connected = false + this.removeEventListener('summer-result', this.onResult) + } + + attributeChangedCallback(name: string, _old: string | null, value: string | null): void { + if (!this.connected) { + return + } + if (name === 'busy' && value === null) { + this.flush() + } + if (name === 'state' && value === 'error') { + this.inflight = null + } + if (name === 'ui-locale') { + this.build(true) + return + } + this.syncControls() + } + + private labels(): Labels { + return labelsFor(uiLocaleOf(this)) + } + + 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 build(force: boolean): void { + const key = uiLocaleOf(this) + '|' + (this.context?.agents ?? DEFAULT_AGENTS).join(',') + if (!force && this.builtFor === key && this.input) { + this.syncControls() + return + } + this.builtFor = key + const labels = this.labels() + const keepUrl = this.input?.value ?? '' + const keepAgent = this.select?.value ?? 'Googlebot' + this.replaceChildren() + + const row = el('div', 'seo-robotstester__row') + const urlId = uid('seo-robotstester-url') + const urlWrap = el('div', 'seo-robotstester__field seo-robotstester__field--url') + const urlLabel = el('label', 'seo-robotstester__label', labels.url) + urlLabel.htmlFor = urlId + const input = el('input', 'seo-robotstester__input') + input.id = urlId + input.type = 'text' + input.placeholder = labels.urlPlaceholder + input.value = keepUrl + input.addEventListener('keydown', (ev) => { + if (ev.key === 'Enter') { + ev.preventDefault() + this.test() + } + }) + urlWrap.append(urlLabel, input) + + const agentId = uid('seo-robotstester-agent') + const agentWrap = el('div', 'seo-robotstester__field seo-robotstester__field--agent') + const agentLabel = el('label', 'seo-robotstester__label', labels.agent) + agentLabel.htmlFor = agentId + const select = el('select', 'seo-robotstester__select') + select.id = agentId + for (const agent of this.context?.agents ?? DEFAULT_AGENTS) { + const option = el('option', '', agent) + option.value = agent + select.append(option) + } + select.value = keepAgent + if (select.value !== keepAgent) { + select.value = 'Googlebot' + } + agentWrap.append(agentLabel, select) + + const button = el('button', 'seo-robotstester__button') + button.type = 'button' + button.addEventListener('click', () => this.test()) + row.append(urlWrap, agentWrap, button) + + const line = el('div', 'seo-robotstester__result') + line.setAttribute('role', 'status') + const notes = el('div', 'seo-robotstester__notes') + this.input = input + this.select = select + this.button = button + this.line = line + this.notes = notes + this.append(row, line, notes) + this.syncControls() + this.renderResult() + } + + private syncControls(): void { + if (!this.button || !this.input || !this.select) { + return + } + const disabled = this.hasAttribute('disabled') + const busy = this.hasAttribute('busy') + this.input.readOnly = disabled + this.select.disabled = disabled + this.button.hidden = disabled + this.button.disabled = busy + this.button.textContent = busy ? this.getAttribute('busy-label') || this.getAttribute('label') || '' : this.getAttribute('label') || '' + } + + private test(): void { + if (this.hasAttribute('disabled') || !this.input || !this.select) { + return + } + const url = this.input.value.trim() + if (!url.startsWith('/')) { + this.error = this.labels().urlRequired + this.result = null + this.renderResult() + return + } + this.error = '' + this.request({ op: 'test', url, agent: this.select.value }) + } + + private renderResult(): void { + if (!this.line || !this.notes) { + return + } + const labels = this.labels() + this.line.className = 'seo-robotstester__result' + if (this.error) { + this.line.classList.add('seo-robotstester__result--error') + this.line.replaceChildren(el('span', '', this.error)) + } else if (this.result) { + const allowed = this.result.allowed + this.line.classList.add(allowed ? 'seo-robotstester__result--allowed' : 'seo-robotstester__result--blocked') + this.line.replaceChildren(icon(allowed ? 'circle-check' : 'triangle-alert'), el('span', '', resultText(labels, this.result))) + } else { + this.line.replaceChildren() + } + const notes: HTMLElement[] = [] + if (this.context?.staging || jsonAttr<{ staging?: boolean }>(this, 'data', {}).staging) { + notes.push(el('p', 'seo-robotstester__note', labels.stagingNote)) + } + notes.push(el('p', 'seo-robotstester__note', labels.unsaved)) + this.notes.replaceChildren(...notes) + } +} diff --git a/assets/src/elements/robotstxt.ts b/assets/src/elements/robotstxt.ts new file mode 100644 index 0000000..64125df --- /dev/null +++ b/assets/src/elements/robotstxt.ts @@ -0,0 +1,239 @@ +import { el, emit, fmt, icon, labelsFor, num, uid, uiLocaleOf, type Labels } from './labels' + +export const ROBOTSTXT_TAG = 'golem15-seo-robotstxt' + +/** The largest robots.txt the server stores: Google reads 500 KiB. */ +export const MAX_ROBOTS_BYTES = 500 * 1024 + +/** The findings shown before "and :count more". */ +export const MAX_FINDINGS = 10 + +/** The directives the lint knows, as written in the suggestion. */ +const DIRECTIVES = ['User-agent', 'Allow', 'Disallow', 'Sitemap', 'Crawl-delay', 'Host', 'Clean-param'] + +export type FindingKind = 'unknown' | 'unknownSuggest' | 'noGroup' | 'badPath' | 'noColon' | 'ownSitemap' | 'crawlDelay' + +export interface Finding { + kind: FindingKind + line: number + word?: string + suggestion?: string +} + +/** Levenshtein distance of two short strings. */ +function distance(a: string, b: string): number { + const row = Array.from({ length: b.length + 1 }, (_, i) => i) + for (let i = 1; i <= a.length; i++) { + let prev = row[0] + row[0] = i + for (let j = 1; j <= b.length; j++) { + const tmp = row[j] + row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1)) + prev = tmp + } + } + return row[b.length] +} + +/** The closest known directive within two edits, or ''. */ +function suggest(word: string): string { + const lower = word.toLowerCase() + let best = '' + let bestDistance = 3 + for (const d of DIRECTIVES) { + const dist = distance(lower, d.toLowerCase()) + if (dist < bestDistance) { + best = d + bestDistance = dist + } + } + return best +} + +/** + * Lints a robots.txt (UI-SPEC E4): every finding is a warning that never + * blocks a save. Lines are 1-based; comments (#) and blank lines are + * skipped. + */ +export function lintRobots(text: string): Finding[] { + const out: Finding[] = [] + let group = false + text.split(/\r?\n/).forEach((raw, index) => { + const line = index + 1 + const hash = raw.indexOf('#') + const body = (hash >= 0 ? raw.slice(0, hash) : raw).trim() + if (body === '') { + return + } + const colon = body.indexOf(':') + if (colon < 0) { + out.push({ kind: 'noColon', line }) + return + } + const word = body.slice(0, colon).trim() + const value = body.slice(colon + 1).trim() + switch (word.toLowerCase()) { + case 'user-agent': + group = true + return + case 'allow': + case 'disallow': + if (!group) { + out.push({ kind: 'noGroup', line }) + } else if (value !== '' && !value.startsWith('/') && !value.startsWith('*')) { + out.push({ kind: 'badPath', line }) + } + return + case 'sitemap': + out.push({ kind: 'ownSitemap', line }) + return + case 'crawl-delay': + out.push({ kind: 'crawlDelay', line }) + return + case 'host': + case 'clean-param': + return + } + const suggestion = suggest(word) + out.push(suggestion ? { kind: 'unknownSuggest', line, word, suggestion } : { kind: 'unknown', line, word }) + }) + return out +} + +/** The UTF-8 size of text in bytes. */ +export function byteSize(text: string): number { + return new TextEncoder().encode(text).length +} + +/** The text of a finding in the element's language. */ +export function findingText(labels: Labels, f: Finding): string { + return fmt(labels[f.kind], { line: f.line, word: f.word ?? '', suggestion: f.suggestion ?? '' }) +} + +/** + * E4 golem15-seo-robotstxt (UI-SPEC C10): the robots.txt editor, a + * monospaced textarea with a live lint panel (at most ten findings) and a + * size meter. Findings never block a save; only a file over 500 KB is + * refused, by the field type's Decode. The value is a plain string; the + * element emits summer-change on every input. + */ +export class SeoRobotsTxtElement extends HTMLElement { + static observedAttributes = ['disabled', 'invalid', 'ui-locale'] + + private current = '' + private control: HTMLTextAreaElement | null = null + private panel: HTMLDivElement | null = null + private meter: HTMLDivElement | 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() + } + + connectedCallback(): void { + this.build() + } + + attributeChangedCallback(name: string): void { + if (!this.isConnected) { + return + } + if (name === 'ui-locale') { + this.build() + return + } + this.sync() + } + + private labels(): Labels { + return labelsFor(uiLocaleOf(this)) + } + + private build(): void { + const key = uiLocaleOf(this) + if (this.builtFor === key && this.control) { + this.sync() + return + } + this.builtFor = key + this.replaceChildren() + const panelId = uid('seo-robotstxt-lint') + const control = el('textarea', 'seo-robotstxt__control') + control.spellcheck = false + control.setAttribute('wrap', 'off') + control.value = this.current + control.setAttribute('aria-describedby', panelId) + 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 + this.update() + emit(this, 'summer-change', { value: this.current }) + }) + const panel = el('div', 'seo-robotstxt__lint') + panel.id = panelId + panel.setAttribute('role', 'status') + const meter = el('div', 'seo-robotstxt__size') + this.control = control + this.panel = panel + this.meter = meter + this.append(control, panel, meter) + this.sync() + } + + private sync(): void { + if (!this.control) { + return + } + this.control.readOnly = this.hasAttribute('disabled') + this.classList.toggle('seo-robotstxt--invalid', this.hasAttribute('invalid')) + this.control.setAttribute('aria-invalid', this.hasAttribute('invalid') ? 'true' : 'false') + this.update() + } + + private update(): void { + if (!this.panel || !this.meter) { + return + } + const labels = this.labels() + const locale = uiLocaleOf(this) + const findings = lintRobots(this.current) + const items: HTMLElement[] = [] + if (findings.length === 0) { + const item = el('div', 'seo-robotstxt__finding seo-robotstxt__finding--clean') + item.append(icon('circle-check'), el('span', '', labels.clean)) + items.push(item) + } else { + for (const f of findings.slice(0, MAX_FINDINGS)) { + const item = el('div', 'seo-robotstxt__finding') + item.append(icon('triangle-alert'), el('span', '', findingText(labels, f))) + items.push(item) + } + if (findings.length > MAX_FINDINGS) { + items.push(el('div', 'seo-robotstxt__more', fmt(labels.moreFindings, { count: num(findings.length - MAX_FINDINGS, locale) }))) + } + } + this.panel.replaceChildren(...items) + const bytes = byteSize(this.current) + const kb = new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(bytes / 1024) + ' KB' + this.meter.replaceChildren(el('span', '', fmt(labels.size, { size: kb }))) + const over = bytes > MAX_ROBOTS_BYTES + this.classList.toggle('seo-robotstxt--over', over) + if (over) { + this.meter.append(el('span', 'seo-robotstxt__over', labels.tooLarge)) + } + } +} diff --git a/assets/src/elements/styles.css b/assets/src/elements/styles.css index 2e27901..b9aa097 100644 --- a/assets/src/elements/styles.css +++ b/assets/src/elements/styles.css @@ -1,11 +1,13 @@ -/* SEO field elements (UI-SPEC C4, C5, C6, C14). Light DOM, every rule +/* SEO field elements (UI-SPEC C4, C5, C6, C10, 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 { +golem15-seo-image, +golem15-seo-robotstxt, +golem15-seo-robotstester { display: block; color: var(--c-text); font-size: 14px; @@ -440,6 +442,187 @@ golem15-seo-image.seo-image--invalid .seo-image__img { border-color: var(--c-danger); } +/* E4 golem15-seo-robotstxt */ +golem15-seo-robotstxt .seo-robotstxt__control { + box-sizing: border-box; + display: block; + width: 100%; + min-height: 320px; + padding: 12px 14px; + border: 1px solid var(--c-border-strong); + border-radius: 10px; + background: var(--c-surface); + color: var(--c-text); + font-family: 'DM Mono', ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 13px; + line-height: 1.6; + white-space: pre; + overflow-x: auto; + resize: vertical; + transition: border-color 150ms, box-shadow 150ms; +} + +golem15-seo-robotstxt .seo-robotstxt__control:focus { + outline: none; + border-color: var(--c-primary); + box-shadow: 0 0 0 3px var(--c-ring); +} + +golem15-seo-robotstxt.seo-robotstxt--invalid .seo-robotstxt__control, +golem15-seo-robotstxt.seo-robotstxt--over .seo-robotstxt__control { + border-color: var(--c-danger); +} + +golem15-seo-robotstxt .seo-robotstxt__lint { + margin-top: 8px; + padding: 12px 14px; + border: 1px solid var(--c-border); + border-radius: 10px; + background: var(--c-subtle); + font-size: 13px; +} + +golem15-seo-robotstxt .seo-robotstxt__finding { + display: flex; + align-items: flex-start; + gap: 6px; + overflow-wrap: anywhere; +} + +golem15-seo-robotstxt .seo-robotstxt__finding + .seo-robotstxt__finding { + margin-top: 4px; +} + +golem15-seo-robotstxt .seo-robotstxt__finding .seo-icon { + flex: none; + margin-top: 3px; +} + +golem15-seo-robotstxt .seo-robotstxt__finding--clean { + color: var(--c-ok-text, var(--c-text)); +} + +golem15-seo-robotstxt .seo-robotstxt__more { + margin-top: 4px; + color: var(--c-muted); +} + +golem15-seo-robotstxt .seo-robotstxt__size { + display: flex; + justify-content: flex-end; + gap: 12px; + margin-top: 6px; + color: var(--c-muted); + font-size: 13px; + font-variant-numeric: tabular-nums; +} + +golem15-seo-robotstxt .seo-robotstxt__over { + color: var(--c-danger); +} + +/* E5 golem15-seo-robotstester */ +golem15-seo-robotstester .seo-robotstester__row { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 8px; +} + +golem15-seo-robotstester .seo-robotstester__field { + display: flex; + flex-direction: column; + gap: 4px; +} + +golem15-seo-robotstester .seo-robotstester__field--url { + flex: 1 1 240px; +} + +golem15-seo-robotstester .seo-robotstester__field--agent { + min-width: 200px; +} + +golem15-seo-robotstester .seo-robotstester__label { + color: var(--c-muted); + font-size: 13px; +} + +golem15-seo-robotstester .seo-robotstester__input, +golem15-seo-robotstester .seo-robotstester__select { + 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; + font-size: 14px; + transition: border-color 150ms, box-shadow 150ms; +} + +golem15-seo-robotstester .seo-robotstester__input:focus, +golem15-seo-robotstester .seo-robotstester__select:focus { + outline: none; + border-color: var(--c-primary); + box-shadow: 0 0 0 3px var(--c-ring); +} + +golem15-seo-robotstester .seo-robotstester__button { + height: 42px; + padding: 0 16px; + 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-robotstester .seo-robotstester__button:focus-visible { + outline: none; + box-shadow: 0 0 0 3px var(--c-ring); +} + +golem15-seo-robotstester .seo-robotstester__button:disabled { + cursor: default; + opacity: 0.6; +} + +golem15-seo-robotstester .seo-robotstester__result { + display: flex; + align-items: flex-start; + gap: 6px; + margin-top: 10px; + font-size: 14px; + overflow-wrap: anywhere; +} + +golem15-seo-robotstester .seo-robotstester__result .seo-icon { + flex: none; + margin-top: 3px; +} + +golem15-seo-robotstester .seo-robotstester__result--allowed { + color: var(--c-ok-text, var(--c-text)); +} + +golem15-seo-robotstester .seo-robotstester__result--blocked { + color: var(--c-text); +} + +golem15-seo-robotstester .seo-robotstester__result--error { + color: var(--c-danger); +} + +golem15-seo-robotstester .seo-robotstester__note { + margin: 6px 0 0; + color: var(--c-muted); + font-size: 13px; +} + @media (max-width: 600px) { golem15-seo-robots .seo-robots__panel { grid-template-columns: 1fr; @@ -451,6 +634,9 @@ golem15-seo-image.seo-image--invalid .seo-image__img { } @media (prefers-reduced-motion: reduce) { + golem15-seo-robotstxt .seo-robotstxt__control, + golem15-seo-robotstester .seo-robotstester__input, + golem15-seo-robotstester .seo-robotstester__select, golem15-seo-text .seo-text__control, golem15-seo-robots .seo-robots__segment, golem15-seo-serp .seo-serp__segment { diff --git a/classes/field_types.go b/classes/field_types.go index 9c49f50..247c490 100644 --- a/classes/field_types.go +++ b/classes/field_types.go @@ -10,12 +10,14 @@ import ( "git.golem15.com/golem15/summercms/modules/pact" ) -// The elements of the SEO field types (assets/src/elements). +// The elements of the SEO field types and widgets (assets/src/elements). const ( - ElementText = "golem15-seo-text" - ElementRobots = "golem15-seo-robots" - ElementSERP = "golem15-seo-serp" - ElementImage = "golem15-seo-image" + ElementText = "golem15-seo-text" + ElementRobots = "golem15-seo-robots" + ElementSERP = "golem15-seo-serp" + ElementImage = "golem15-seo-image" + ElementRobotsTxt = "golem15-seo-robotstxt" + ElementRobotsTester = "golem15-seo-robotstester" ) // The element bundle every SEO field type loads. @@ -27,13 +29,18 @@ var ( // errRobotsInvalid is the refusal of a malformed robots value. const errRobotsInvalid = "golem15.seo::lang.robots.invalid" +// errRobotsTooLarge refuses a robots.txt over MaxRobotsBytes. +const errRobotsTooLarge = "golem15.seo::lang.robots.too_large" + // FieldTypes returns the SEO admin field types: // - seotext and mlseotext (E1): text with a live length counter; option // measure: title (input) or description (textarea); // - seorobots and mlseorobots (E2): the robots directives editor; option // scope: record, rule or type; Decode refuses a malformed value; // - seoimage (E7): an uploaded social image stored in the media bucket, -// its URL as the value (D-100); uploads need permission. +// its URL as the value (D-100); uploads need permission; +// - robotstxt (E4): the robots.txt editor with live lint; Decode refuses +// a file over 500 KB (D-22). // // app is asked for the booted application at request time. func FieldTypes(app func() *backpack.App, permission string) []pact.FormFieldType { @@ -61,9 +68,29 @@ func FieldTypes(app func() *backpack.App, permission string) []pact.FormFieldTyp Scripts: elementScripts, Styles: elementStyles, Decode: decodeImage, Upload: UploadImage(app, permission), }, + { + Name: "robotstxt", Element: ElementRobotsTxt, + Scripts: elementScripts, Styles: elementStyles, + Decode: decodeRobotsTxt, + }, } } +// decodeRobotsTxt stores a robots.txt with Unix line ends and refuses one +// over MaxRobotsBytes (robots.too_large); lint findings are warnings in +// the element and never block a save. +func decodeRobotsTxt(_ context.Context, in pact.FormFieldValue) (pact.FormFieldDecoded, error) { + s, ok := stringValue(in.Value) + if !ok { + return pact.FormFieldDecoded{}, &pact.FormFieldError{Messages: []string{"The " + in.Field + " field must be text."}} + } + s = strings.ReplaceAll(s, "\r\n", "\n") + if len(s) > MaxRobotsBytes { + return pact.FormFieldDecoded{}, &pact.FormFieldError{Messages: []string{errRobotsTooLarge}} + } + return pact.FormFieldDecoded{Value: s}, nil +} + func checkMeasure(options map[string]any) error { if raw, ok := options["measure"]; ok { if s, _ := raw.(string); s != "title" && s != "description" { diff --git a/controllers/admin_registry.go b/controllers/admin_registry.go index ef88832..27f654c 100644 --- a/controllers/admin_registry.go +++ b/controllers/admin_registry.go @@ -32,6 +32,7 @@ func AdminControllers(app func() *backpack.App) []pact.AdminController { redirectsAdminController{app: app}, notFoundAdminController{app: app}, rulesAdminController{app: app}, + robotsAdminController{app: app}, historyAdminController{app: app}, } } diff --git a/controllers/robots.go b/controllers/robots.go new file mode 100644 index 0000000..7554862 --- /dev/null +++ b/controllers/robots.go @@ -0,0 +1,101 @@ +package controllers + +import ( + "context" + "errors" + + "git.golem15.com/golem15/sm-seo-plugin/classes" + "git.golem15.com/golem15/sm-seo-plugin/models" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/pact" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// RobotsControllerID is the robots.txt screen (UI-SPEC C10). +const RobotsControllerID = "golem15.seo.robots" + +// robotsAdminController edits the robots.txt singleton (D-22, D-24, +// D-25). Its form holds the staging callout and the served file (P4); the +// editor (E4), the tester (E5) and the AI bot switches come from the +// plugin's own form extension on this controller, so the tester can read +// the unsaved editor text and switches. +type robotsAdminController struct { + app func() *backpack.App +} + +func (robotsAdminController) ID() string { return RobotsControllerID } +func (robotsAdminController) ModelName() string { return `Golem15\SEO\Models\Robots` } +func (robotsAdminController) ConfigDir() string { return "controllers/robots" } +func (robotsAdminController) NewRecord() any { return &models.Robots{} } +func (robotsAdminController) RequiredPermissions() []string { + return []string{PermissionManage} +} + +// SingletonRecord returns robots row 1, creating it when the migration's +// row is gone (first or create, safe against a concurrent first request). +func (robotsAdminController) SingletonRecord(_ context.Context, db *gorm.DB) (any, error) { + if db == nil { + return nil, errors.New("golem15.seo: database is not configured") + } + var rows []models.Robots + if err := db.Where("id = ?", 1).Limit(1).Find(&rows).Error; err != nil { + return nil, err + } + if len(rows) > 0 { + return &rows[0], nil + } + row := models.Robots{ID: 1, Bots: models.BotSwitches{}} + if err := db.Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error; err != nil { + return nil, err + } + if err := db.Where("id = ?", 1).Limit(1).Find(&rows).Error; err != nil || len(rows) == 0 { + return nil, errors.New("golem15.seo: robots row is missing") + } + return &rows[0], nil +} + +// StagingView is the staging callout partial's view model. +type StagingView struct { + Staging bool +} + +// ServedView is P4: the file crawlers get and, outside production, the +// production version under it. +type ServedView struct { + Served string + Production string + Staging bool +} + +// PartialData serves the staging callout and P4. +func (c robotsAdminController) PartialData(ctx context.Context, name string, _ any) (any, error) { + svc, err := classes.NewService(appOf(c.app)) + if err != nil { + return nil, err + } + staging := svc.Staging() + switch name { + case "staging": + return StagingView{Staging: staging}, nil + case "robots_served": + production, err := svc.ServedFile(ctx, true) + if err != nil { + return nil, err + } + view := ServedView{Served: production, Staging: staging} + if staging { + view.Served, view.Production = classes.NonProductionRobots, production + } + return view, nil + } + return nil, nil +} + +var ( + _ pact.AdminController = robotsAdminController{} + _ pact.AdminPermissioned = robotsAdminController{} + _ pact.AdminRecordSource = robotsAdminController{} + _ pact.AdminSingleton = robotsAdminController{} + _ pact.AdminPartialData = robotsAdminController{} +) diff --git a/controllers/robots/_robots_served.htm b/controllers/robots/_robots_served.htm new file mode 100644 index 0000000..d6f913e --- /dev/null +++ b/controllers/robots/_robots_served.htm @@ -0,0 +1,10 @@ +{{- with .Data -}} +
+

{{ trans "golem15.seo::lang.robots.served_comment" }}

+
{{ .Served }}
+{{- if .Staging }} +

{{ trans "golem15.seo::lang.robots.served_production" }}

+
{{ .Production }}
+{{- end }} +
+{{- end -}} diff --git a/controllers/robots/_staging.htm b/controllers/robots/_staging.htm new file mode 100644 index 0000000..5f2fea2 --- /dev/null +++ b/controllers/robots/_staging.htm @@ -0,0 +1,6 @@ +{{- if .Data.Staging -}} +
+

{{ trans "golem15.seo::lang.robots.staging_title" }}

+

{{ trans "golem15.seo::lang.robots.staging_text" }}

+
+{{- end -}} diff --git a/controllers/robots/config_form.yaml b/controllers/robots/config_form.yaml new file mode 100644 index 0000000..d6686d1 --- /dev/null +++ b/controllers/robots/config_form.yaml @@ -0,0 +1,13 @@ +# robots.txt singleton (UI-SPEC C10): the singleton footer, no delete. +name: golem15.seo::lang.robots.title +form: ~/plugins/golem15/seo/models/robots/fields.yaml +modelClass: Golem15\SEO\Models\Robots +defaultRedirect: golem15/seo/robots + +update: + redirect: golem15/seo/robots + redirectClose: golem15/seo/redirects + +messages: + update: golem15.seo::lang.robots.title + saved: backend::lang.messages.form.saved diff --git a/extensions.go b/extensions.go index 81026e9..6246dd7 100644 --- a/extensions.go +++ b/extensions.go @@ -39,6 +39,7 @@ func (p *Plugin) AdminActionExtensions() []pact.AdminActionExtension { } out = append(out, pact.AdminActionExtension{Controller: controllers.RulesControllerID, Form: p.ruleHistory()}) out = append(out, pact.AdminActionExtension{Controller: controllers.RedirectsControllerID, Form: p.redirectChecks()}) + out = append(out, pact.AdminActionExtension{Controller: controllers.RobotsControllerID, Form: p.robotsForm()}) return out } diff --git a/extensions_robots.go b/extensions_robots.go new file mode 100644 index 0000000..b57f031 --- /dev/null +++ b/extensions_robots.go @@ -0,0 +1,252 @@ +package seo + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "git.golem15.com/golem15/sm-seo-plugin/classes" + "git.golem15.com/golem15/sm-seo-plugin/controllers" + "git.golem15.com/golem15/sm-seo-plugin/models" + "git.golem15.com/golem15/summercms/modules/cabana" + "git.golem15.com/golem15/summercms/modules/pact" + "github.com/goccy/go-yaml" +) + +// The robots.txt form's tab keys (UI-SPEC C10). +const ( + TabRobotsFile = "golem15.seo::lang.robots.tab_file" + TabRobotsBots = "golem15.seo::lang.robots.tab_bots" +) + +// RobotsTestAction is the tester's widget action (E5). +const RobotsTestAction = "robots_test" + +// botComments are the switch comments per bot kind. +var botComments = map[string]string{ + classes.BotSearch: "golem15.seo::lang.bots.search_comment", + classes.BotUser: "golem15.seo::lang.bots.user_comment", + classes.BotTraining: "golem15.seo::lang.bots.training_comment", +} + +// robotsDocument builds the robots form's fields document: the editor +// (E4) and the tester (E5, filled from the editor and every bot switch) +// after the staging callout on the robots.txt tab, then the AI bots tab +// with one switch per bot in classes.Bots order, labelled with its token +// and operator. +func robotsDocument() ([]byte, error) { + type tabSpec struct { + Name string `yaml:"name"` + After string `yaml:"after"` + } + fill := []string{"content"} + for _, b := range classes.Bots { + fill = append(fill, classes.BotField(b.Token)) + } + fields := yaml.MapSlice{ + {Key: "content", Value: yaml.MapSlice{ + {Key: "label", Value: "golem15.seo::lang.robots.content"}, + {Key: "comment", Value: "golem15.seo::lang.robots.content_comment"}, + {Key: "type", Value: "robotstxt"}, + {Key: "span", Value: "full"}, + {Key: "after", Value: "staging"}, + {Key: "tab", Value: TabRobotsFile}, + }}, + {Key: "tester", Value: yaml.MapSlice{ + {Key: "label", Value: "golem15.seo::lang.robots.tester"}, + {Key: "type", Value: "widget"}, + {Key: "widget", Value: classes.ElementRobotsTester}, + {Key: "action", Value: RobotsTestAction}, + {Key: "fill", Value: fill}, + {Key: "span", Value: "full"}, + {Key: "after", Value: "content"}, + {Key: "tab", Value: TabRobotsFile}, + }}, + } + for i, b := range classes.Bots { + span := "left" + if i%2 == 1 { + span = "right" + } + fields = append(fields, yaml.MapItem{Key: classes.BotField(b.Token), Value: yaml.MapSlice{ + {Key: "label", Value: b.Token + " (" + b.Operator + ")"}, + {Key: "comment", Value: botComments[b.Kind]}, + {Key: "type", Value: "switch"}, + {Key: "default", Value: true}, + {Key: "span", Value: span}, + {Key: "tab", Value: TabRobotsBots}, + }}) + } + return yaml.Marshal(yaml.MapSlice{ + {Key: "tabs", Value: []tabSpec{{Name: TabRobotsBots, After: TabRobotsFile}}}, + {Key: "fields", Value: fields}, + }) +} + +// robotsForm is the plugin's own extension on the robots.txt screen +// (D-22, D-24, D-25): the editor, the tester and the AI bot switches, +// loaded from and saved to the robots row inside the save transaction. +// Save records history and answers robots.blocks_noindex on content when +// the rules block a noindex address from Googlebot. +func (p *Plugin) robotsForm() *pact.AdminFormExtension { + doc, err := robotsDocument() + if err != nil { + panic(fmt.Errorf("golem15.seo: robots form: %w", err)) + } + return &pact.AdminFormExtension{ + FieldsYAML: doc, + Permissions: []string{controllers.PermissionManage}, + Actions: []pact.AdminAction{p.robotsTestAction()}, + Load: func(_ context.Context, in pact.AdminFormLoad) (map[string]any, error) { + row, ok := in.Record.(*models.Robots) + if !ok || row == nil { + return nil, nil + } + out := map[string]any{"content": row.Content} + for _, b := range classes.Bots { + out[classes.BotField(b.Token)] = row.Bots.Allowed(b.Token) + } + return out, nil + }, + Save: func(ctx context.Context, in pact.AdminFormSave) (pact.AdminFormSaveResult, error) { + svc := p.Service() + if svc == nil { + return pact.AdminFormSaveResult{}, classes.ErrNotBooted + } + row, ok := in.Record.(*models.Robots) + if !ok || row == nil { + return pact.AdminFormSaveResult{}, nil + } + before := robotsValues(*row) + if raw, ok := in.Values["content"]; ok { + if text, ok := raw.(string); ok { + row.Content = text + } + } + bots := models.BotSwitches{} + for token, on := range row.Bots { + bots[token] = on + } + for _, b := range classes.Bots { + if raw, ok := in.Values[classes.BotField(b.Token)]; ok { + bots[b.Token] = truthy(raw) + } + } + row.Bots = bots + now := time.Now().UTC() + row.UpdatedAt = &now + if err := in.DB.Model(&models.Robots{}).Where("id = ?", row.ID).Updates(map[string]any{ + "content": row.Content, "bots": row.Bots, "updated_at": now, + }).Error; err != nil { + return pact.AdminFormSaveResult{}, err + } + if err := svc.Record(ctx, in.DB, classes.Change{ + Kind: models.SubjectRobots, + SubjectID: strconv.FormatUint(uint64(row.ID), 10), + What: svc.Phrase("golem15.seo::lang.history.what_robots", nil), + Before: before, + After: robotsValues(*row), + }); err != nil { + return pact.AdminFormSaveResult{}, err + } + blocked, err := svc.BlockedNoindex(in.DB, row.Content, row.Bots) + if err != nil || len(blocked) == 0 { + return pact.AdminFormSaveResult{}, err + } + list := blocked + if len(list) > 10 { + list = append(append([]string{}, list[:10]...), "…") + } + return pact.AdminFormSaveResult{Notices: map[string][]pact.FormFieldNotice{"content": {{ + Message: "golem15.seo::lang.robots.blocks_noindex", + Params: map[string]string{"list": strings.Join(list, ", ")}, + Count: len(blocked), + }}}}, nil + }, + } +} + +// robotsValues are the robots row's values for history. +func robotsValues(r models.Robots) map[string]any { + bots := map[string]any{} + for _, b := range classes.Bots { + bots[b.Token] = r.Bots.Allowed(b.Token) + } + return map[string]any{"content": r.Content, "bots": bots} +} + +// robotsTestPayload is the tester's request: op context answers the +// crawler list and the environment; op test answers whether agent may +// fetch url under the editor's unsaved text and switches. +type robotsTestPayload struct { + Op string `json:"op"` + URL string `json:"url"` + Agent string `json:"agent"` +} + +// robotsTesterContext is the tester's context answer. +type robotsTesterContext struct { + Op string `json:"op"` + Agents []string `json:"agents"` + Bots []string `json:"bots"` + Staging bool `json:"staging"` +} + +// robotsTestResult is the tester's test answer. +type robotsTestResult struct { + Op string `json:"op"` + URL string `json:"url"` + classes.RobotsTest +} + +// testerAgents are the crawlers E5 offers before the AI bots. +var testerAgents = []string{"Googlebot", "Googlebot-Image", "Bingbot"} + +// robotsTestAction is the tester's widget action (E5, D-22, D-99). +func (p *Plugin) robotsTestAction() pact.AdminAction { + return pact.AdminAction{ + Name: RobotsTestAction, + Label: "golem15.seo::lang.robots.test", + Permissions: []string{controllers.PermissionManage}, + Run: func(ctx context.Context, in pact.AdminActionInput) (pact.AdminActionResult, error) { + svc := p.Service() + if svc == nil { + return pact.AdminActionResult{}, classes.ErrNotBooted + } + var payload robotsTestPayload + if len(in.Payload) > 0 { + if err := json.Unmarshal(in.Payload, &payload); err != nil { + return pact.AdminActionResult{}, &cabana.ValidationError{Details: map[string]any{in.Field: []string{"golem15.seo::lang.robots.test_invalid"}}} + } + } + if payload.Op == "context" || payload.Op == "" { + out := robotsTesterContext{Op: "context", Agents: append([]string{}, testerAgents...), Staging: svc.Staging()} + for _, b := range classes.Bots { + out.Agents = append(out.Agents, b.Token) + out.Bots = append(out.Bots, b.Token) + } + out.Agents = append(out.Agents, "*") + return pact.AdminActionResult{Data: out}, nil + } + path := strings.TrimSpace(payload.URL) + if !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "//") || len(path) > classes.MaxURLBytes { + return pact.AdminActionResult{}, &cabana.ValidationError{Details: map[string]any{in.Field: []string{"golem15.seo::lang.robots.url_required"}}} + } + agent := strings.TrimSpace(payload.Agent) + if agent == "" { + agent = "Googlebot" + } + content, _ := in.Values["content"].(string) + bots := models.BotSwitches{} + for _, b := range classes.Bots { + if raw, ok := in.Values[classes.BotField(b.Token)]; ok { + bots[b.Token] = truthy(raw) + } + } + return pact.AdminActionResult{Data: robotsTestResult{Op: "test", URL: path, RobotsTest: svc.TestRobots(content, bots, agent, path)}}, nil + }, + } +} diff --git a/field_types.go b/field_types.go index 1118ca9..7d32556 100644 --- a/field_types.go +++ b/field_types.go @@ -8,9 +8,10 @@ import ( ) // FormFieldTypes registers the SEO field types (classes.FieldTypes): -// seotext, mlseotext, seorobots, mlseorobots and seoimage. Their element -// bundle loads on every admin page, so the SEO tab works on other plugins' -// forms. +// seotext, mlseotext, seorobots, mlseorobots, seoimage and robotstxt. +// Their element bundle loads on every admin page, so the SEO tab works on +// other plugins' forms and the robots.txt tester element (E5) is defined +// wherever its widget renders. func (p *Plugin) FormFieldTypes() []pact.FormFieldType { return classes.FieldTypes(func() *backpack.App { if p == nil { diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml index a22c6ef..1b3d2c1 100644 --- a/lang/en/lang.yaml +++ b/lang/en/lang.yaml @@ -2,6 +2,7 @@ plugin: name: SEO description: Search engine metadata for every content type, URL rules and global SEO settings. nav: + robots: robots.txt notfound: 404 log redirects: Redirects seo: SEO @@ -38,6 +39,23 @@ robots: inherit: Inherit site_default: Site default staging_title: This is not the production environment + title: "robots.txt" + tab_file: "robots.txt" + tab_bots: "AI bots" + staging_text: "Crawlers get “Disallow: /” and every page is sent with “X-Robots-Tag: noindex”, whatever you save here. The text below applies in production." + content: "robots.txt rules" + content_comment: "Sitemap lines and the AI bot rules are added automatically when the file is served." + tester: "Test an address" + served: "What crawlers get" + served_comment: "The saved version, as served at /robots.txt. Save to update it." + served_production: "Production" + too_large: "robots.txt may be at most 500 KB. Google ignores everything after that." + test: "Test URL" + test_invalid: "The test request is not valid." + url_required: "Enter an address starting with /." + blocks_noindex: + one: ":count noindex address is blocked by these rules, so Google cannot see its noindex: :list" + other: ":count noindex addresses are blocked by these rules, so Google cannot see their noindex: :list" status: rule_title: A URL rule overrides part of this tab rule_text: The rule for “:url” sets :fields. Change them in SEO › URL rules. @@ -268,3 +286,7 @@ notfound: delete_selected_confirm: one: "Remove :count address from the log? It is added again on its next 404." other: "Remove :count addresses from the log? They are added again on their next 404." +bots: + search_comment: "AI search and answers. Off blocks it in robots.txt." + training_comment: "Collects content for AI training. Off blocks it in robots.txt." + user_comment: "Fetches a page when a user asks for it. Off blocks it in robots.txt." diff --git a/lang/pl/lang.yaml b/lang/pl/lang.yaml index b695886..5472a4e 100644 --- a/lang/pl/lang.yaml +++ b/lang/pl/lang.yaml @@ -2,6 +2,7 @@ plugin: name: SEO description: Metadane dla wyszukiwarek we wszystkich typach treści, reguły adresów i globalne ustawienia SEO. nav: + robots: robots.txt notfound: Dziennik 404 redirects: Przekierowania seo: SEO @@ -38,6 +39,25 @@ robots: inherit: Dziedzicz site_default: Domyślne staging_title: To nie jest środowisko produkcyjne + title: "robots.txt" + tab_file: "robots.txt" + tab_bots: "Boty AI" + staging_text: "Roboty dostają „Disallow: /”, a każda strona jest wysyłana z „X-Robots-Tag: noindex”, niezależnie od tego, co tu zapiszesz. Poniższy tekst obowiązuje na produkcji." + content: "Reguły robots.txt" + content_comment: "Wiersze Sitemap i reguły botów AI są dodawane automatycznie przy serwowaniu pliku." + tester: "Sprawdź adres" + served: "Co otrzymują roboty" + served_comment: "Zapisana wersja, tak jak jest serwowana pod /robots.txt. Zapisz, aby ją odświeżyć." + served_production: "Produkcja" + too_large: "robots.txt może mieć najwyżej 500 KB. Google ignoruje wszystko powyżej." + test: "Sprawdź adres" + test_invalid: "Żądanie testu jest nieprawidłowe." + url_required: "Wpisz adres zaczynający się od /." + blocks_noindex: + one: ":count adres z noindex jest blokowany przez te reguły, więc Google nie zobaczy noindex: :list" + few: ":count adresy z noindex są blokowane przez te reguły, więc Google nie zobaczy noindex: :list" + many: ":count adresów z noindex jest blokowanych przez te reguły, więc Google nie zobaczy noindex: :list" + other: ":count adresu z noindex jest blokowane przez te reguły, więc Google nie zobaczy noindex: :list" status: rule_title: Reguła adresu nadpisuje część tej zakładki rule_text: 'Reguła dla „:url” ustawia: :fields. Zmień je w SEO › Reguły adresów.' @@ -276,3 +296,7 @@ notfound: few: "Usunąć :count adresy z dziennika? Wrócą przy następnym błędzie 404." many: "Usunąć :count adresów z dziennika? Wrócą przy następnym błędzie 404." other: "Usunąć :count adresu z dziennika? Wrócą przy następnym błędzie 404." +bots: + search_comment: "Wyszukiwanie i odpowiedzi AI. Wyłączenie blokuje go w robots.txt." + training_comment: "Zbiera treści do trenowania AI. Wyłączenie blokuje go w robots.txt." + user_comment: "Pobiera stronę na prośbę użytkownika. Wyłączenie blokuje go w robots.txt." diff --git a/models/robots/fields.yaml b/models/robots/fields.yaml new file mode 100644 index 0000000..89a991d --- /dev/null +++ b/models/robots/fields.yaml @@ -0,0 +1,16 @@ +# robots.txt screen (UI-SPEC C10), tab robots.txt: the staging callout and +# the served file (P4). The plugin's own form extension on the robots +# controller adds the editor (E4) and the tester (E5) between them, and the +# AI bots tab. +fields: + staging: + type: partial + path: staging + span: full + tab: golem15.seo::lang.robots.tab_file + served: + label: golem15.seo::lang.robots.served + type: partial + path: robots_served + span: full + tab: golem15.seo::lang.robots.tab_file diff --git a/robots_test.go b/robots_test.go index a837767..dda4472 100644 --- a/robots_test.go +++ b/robots_test.go @@ -113,3 +113,81 @@ func TestRobotsTester(t *testing.T) { t.Fatalf("a bot that is on = %+v, want allowed", got) } } + +// TestRobotsScreen smoke-tests the robots.txt screen (UI-SPEC C10, D-22, +// D-24, D-25): the form layout, a save of the rules and a bot switch with +// history, the noindex-blocked notice, the size refusal, the tester action +// on unsaved values and the served-file partial. +func TestRobotsScreen(t *testing.T) { + env := bootEnv(t) + token := env.admin(t, true) + api := adminAPI("/golem15/seo/robots") + + fields := env.formSchema(t, token, "golem15/seo/robots") + var names []string + for _, f := range fields { + names = append(names, f.Name) + } + if strings.Join(names[:4], ",") != "staging,content,tester,served" || len(names) != 4+11 || names[4] != "bot_oai_searchbot" || names[8] != "bot_claude_user" { + t.Fatalf("robots form fields = %v, want staging, content, tester, served, then eleven bot switches", names) + } + + env.expect(t, http.StatusCreated, http.MethodPost, adminAPI("/golem15/seo/rules"), token, map[string]any{"url": "/private/x", "match": "exact", "robots": "noindex,follow"}) + rec := env.expect(t, http.StatusOK, http.MethodPut, api+"/1", token, map[string]any{ + "content": "User-agent: *\r\nDisallow: /private\r\n", "bot_gptbot": false, + }) + var notice bool + for _, n := range saveNotices(t, rec.Body.Bytes()) { + if n.Field == "content" && strings.Contains(n.Message, "1 noindex address is blocked") && strings.Contains(n.Message, "/private/x") { + notice = true + } + } + if !notice { + t.Fatalf("save = %s, want robots.blocks_noindex on content", rec.Body.String()) + } + var row struct { + Content string + Bots string + } + if err := env.db.Raw(`SELECT content, bots::text AS bots FROM golem15_seo_robots WHERE id = 1`).Scan(&row).Error; err != nil { + t.Fatal(err) + } + if row.Content != "User-agent: *\nDisallow: /private\n" || !strings.Contains(row.Bots, `"GPTBot": false`) { + t.Fatalf("stored robots = %+v, want the rules with Unix line ends and GPTBot off", row) + } + if rows := env.revisions(t, "robots"); len(rows) != 1 || rows[0].Via != "admin" { + t.Fatalf("robots history = %+v, want one admin revision", rows) + } + show := env.expect(t, http.StatusOK, http.MethodGet, api+"/1", token, nil).Body.String() + if !strings.Contains(show, `"bot_gptbot":false`) || !strings.Contains(show, `"bot_ccbot":true`) { + t.Fatalf("show = %s, want the switches", show) + } + served := env.expect(t, http.StatusOK, http.MethodGet, api+"/partials/robots_served?id=1", token, nil).Body.String() + if !strings.Contains(served, "User-agent: GPTBot") || !strings.Contains(served, "Sitemap: "+siteURL+"/sitemap.xml") { + t.Fatalf("served partial = %s, want the composed file", served) + } + + rec = env.call(t, http.MethodPut, api+"/1", token, map[string]any{"content": strings.Repeat("#", 501*1024)}) + if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "at most 500 KB") { + t.Fatalf("oversized robots.txt = %d, want 422 robots.too_large", rec.Code) + } + + rec = env.expect(t, http.StatusOK, http.MethodPost, api+"/widgets/tester", token, map[string]any{ + "record_id": 1, "values": map[string]any{"content": "User-agent: *\nDisallow: /a\n", "bot_gptbot": true}, + "payload": map[string]any{"op": "test", "url": "/a/b", "agent": "Googlebot"}, + }) + if body := rec.Body.String(); !strings.Contains(body, `"allowed":false`) || !strings.Contains(body, `"line":2`) || !strings.Contains(body, `"rule":"Disallow: /a"`) { + t.Fatalf("tester = %s, want blocked by line 2 of the unsaved text", body) + } + rec = env.expect(t, http.StatusOK, http.MethodPost, api+"/widgets/tester", token, map[string]any{ + "record_id": 1, "values": map[string]any{"content": "", "bot_claude_user": false}, + "payload": map[string]any{"op": "test", "url": "/", "agent": "Claude-User"}, + }) + if body := rec.Body.String(); !strings.Contains(body, `"bot":true`) { + t.Fatalf("tester with Claude-User off = %s, want blocked by its switch", body) + } + rec = env.expect(t, http.StatusOK, http.MethodPost, api+"/widgets/tester", token, map[string]any{"record_id": 1, "payload": map[string]any{"op": "context"}}) + if body := rec.Body.String(); !strings.Contains(body, `"Claude-User"`) || !strings.Contains(body, `"*"`) || !strings.Contains(body, `"Googlebot-Image"`) { + t.Fatalf("tester context = %s, want the crawler list", body) + } +}