domma-cms 1.8.39 → 1.8.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.8.40] - 2026-10-22
4
+
5
+ - **Every collection entry is in the page for robots.** An interactive collection or view now puts every entry in the page HTML for robots that run no JavaScript, also when it loads its pages from the server, up to 1,000 (was 200, and only the first page in server mode). A collection past 1,000 shows as a warning in the SEO audit.
6
+
3
7
  ## [1.8.39] - 2026-10-22
4
8
 
5
9
  - **Google Analytics, with consent.** Paste your GA4 measurement ID at **Site Settings > Analytics** and Google Analytics runs on every page - but only for visitors who accept Analytics cookies in the cookie banner, which is shown whenever an ID is set. Visitors who reject them, or have not answered, are never sent to Google, and withdrawing consent switches it off and removes its cookies. Adding an ID asks every visitor again, since an earlier answer was not consent to Google. GA's cookies stay on your site's own address, never one shared with other sites. The site's security policy lets Google in only while an ID is set.
@@ -1 +1 @@
1
- export const LIMITS={titleMin:30,titleMax:60,descMin:70,descMax:160,minWords:300};const A={amp:"&",lt:"<",gt:">",quot:'"',apos:"'",nbsp:" ",ndash:"\u2013",mdash:"\u2014",hellip:"\u2026",lsquo:"\u2018",rsquo:"\u2019",ldquo:"\u201C",rdquo:"\u201D",pound:"\xA3",euro:"\u20AC",copy:"\xA9"};export function decode(e){return String(e??"").replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi,(s,n)=>{if(n[0]==="#"){const r=n[1]==="x"||n[1]==="X"?parseInt(n.slice(2),16):parseInt(n.slice(1),10);return Number.isFinite(r)&&r>0&&r<1114112?String.fromCodePoint(r):s}return A[n.toLowerCase()]??s})}const M=e=>decode(String(e??"").replace(/<[^>]*>/g," ")).replace(/\s+/g," ").trim();export function attrs(e){const s={},n=String(e).replace(/^<\s*[a-z0-9-]+/i,"").replace(/\/?>$/,""),r=/([^\s"'=<>`/]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;let i;for(;i=r.exec(n);)s[i[1].toLowerCase()]=decode(i[2]??i[3]??i[4]??"");return s}export function scanHtml(e){const s=String(e??""),n=s.search(/<\/head\s*>/i),r=n>=0?s.slice(0,n):s,i=[];for(const l of s.matchAll(/<script\b[^>]*type\s*=\s*["']?application\/ld\+json["']?[^>]*>([\s\S]*?)<\/script>/gi))try{const c=JSON.parse(l[1]);for(const h of[c,...Array.isArray(c)?c:[],...Array.isArray(c?.["@graph"])?c["@graph"]:[]]){const o=h?.["@type"];o&&i.push(...(Array.isArray(o)?o:[o]).map(String))}}catch{i.push("(invalid)")}const a=s.replace(/<!--[\s\S]*?-->/g," ").replace(/<(script|style|template|noscript|svg)\b[\s\S]*?<\/\1\s*>/gi," ").replace(/<div\b[^>]*\bclass\s*=\s*["'][^"']*\b(?:cms-nojs-links|cms-form-embed-summary)\b[^>]*>[\s\S]*?<\/div\s*>/gi," "),p=[...r.matchAll(/<meta\b[^>]*>/gi)].map(l=>attrs(l[0])),t=(l,c)=>p.find(h=>(h[l]||"").toLowerCase()===c)?.content,$=[...r.matchAll(/<link\b[^>]*>/gi)].map(l=>attrs(l[0])),d=a.search(/<body\b/i),u=d>=0?a.slice(d):a,m=[...u.matchAll(/<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1\s*>/gi)].map(l=>({level:Number(l[1]),text:M(l[2])})),w=[...u.matchAll(/<img\b[^>]*>/gi)].map(l=>{const c=attrs(l[0]);return{src:c.src||c["data-src"]||"",alt:"alt"in c?c.alt:null,role:c.role||""}}),g=[...u.matchAll(/<a\b([^>]*)>([\s\S]*?)<\/a\s*>/gi)].map(l=>{const c=attrs(`<a ${l[1]}>`);return{href:c.href||"",text:M(l[2]),rel:c.rel||"",title:c.title||""}}),y=M(u),x=M(u.match(/<p\b[^>]*>([\s\S]*?)<\/p\s*>/i)?.[1]||"");return{title:M(r.match(/<title\b[^>]*>([\s\S]*?)<\/title\s*>/i)?.[1]||""),description:t("name","description")??null,robots:t("name","robots")||"",viewport:!!t("name","viewport"),canonical:$.find(l=>(l.rel||"").toLowerCase()==="canonical")?.href||"",lang:attrs(s.match(/<html\b[^>]*>/i)?.[0]||"<html>").lang||"",og:{title:t("property","og:title")||"",description:t("property","og:description")||"",image:t("property","og:image")||"",type:t("property","og:type")||""},twitter:{card:t("name","twitter:card")||"",image:t("name","twitter:image")||""},headings:m,images:w,links:g,words:y?y.split(/\s+/).filter(l=>/[\p{L}\p{N}]/u.test(l)).length:0,text:y.slice(0,2e4),firstPara:x.slice(0,1e3),jsonLdTypes:[...new Set(i)]}}const f=(e,s,n=`${s}s`)=>`${e} ${e===1?s:n}`,v=(e,s)=>!!s&&String(e||"").toLowerCase().includes(s),T=e=>String(e||"").toLowerCase().normalize("NFKD").replace(/[̀-ͯ]/g,"").replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"");export function analyse(e,{urlPath:s="/",focus:n="",limits:r={},origin:i=""}={}){const a={...LIMITS,...r},p=[],t=(o,b,k,L,S,C="")=>p.push({id:o,group:b,status:k,title:L,detail:S,fix:C}),$=/noindex/i.test(e.robots),d=e.title.length;d?d<a.titleMin?t("title","Search result","warn","Title",`${d} characters - short. Search engines may write their own.`,`Aim for ${a.titleMin}-${a.titleMax} characters that say what the page is about.`):d>a.titleMax?t("title","Search result","warn","Title",`${d} characters - it will be cut off after about ${a.titleMax}.`,"Put the words that matter first, or shorten it."):t("title","Search result","pass","Title",`${d} characters.`):t("title","Search result","fail","Title","The page has no title.","Give it one: it is the blue link people click in the results.");const u=e.description,m=(u||"").length;m?m<a.descMin?t("description","Search result","warn","Description",`${m} characters - short.`,`Aim for ${a.descMin}-${a.descMax} characters.`):m>a.descMax?t("description","Search result","warn","Description",`${m} characters - it will be cut off after about ${a.descMax}.`,"Shorten it, keeping the point in the first half."):t("description","Search result","pass","Description",`${m} characters.`):t("description","Search result","fail","Description","No description: search engines pick any sentence from the page.","Write one or two sentences that make someone want to click.");const w=s.split("/").filter(Boolean).pop()||"";s!=="/"&&(w.length>60||/[A-Z_ %]/.test(w)||/\d{5,}/.test(w))?t("url","Search result","warn","Address",`"${w}" is long or hard to read.`,"Short, lower-case words joined by hyphens read best - rename the page (a redirect is added for you)."):t("url","Search result","pass","Address",s==="/"?"The home page.":`"${w}" reads well.`);const g=e.headings.filter(o=>o.level===1);g.length?g.length>1?t("h1","Content","warn","Main heading",`${g.length} H1 headings: "${g.slice(0,3).map(o=>o.text).join('", "')}".`,"Keep one H1; make the others H2."):g[0].text?t("h1","Content","pass","Main heading",`"${g[0].text.slice(0,80)}".`):t("h1","Content","warn","Main heading","The H1 heading is empty.","Give it words."):t("h1","Content","fail","Main heading","There is no H1 heading.","Start the page with one main heading (# in Markdown) saying what it is about.");const y=[];for(let o=1;o<e.headings.length;o++)e.headings[o].level>e.headings[o-1].level+1&&y.push(`H${e.headings[o-1].level} \u2192 H${e.headings[o].level}`);y.length?t("headings","Content","warn","Heading order",`Levels are skipped: ${[...new Set(y)].slice(0,3).join(", ")}.`,"Go down one level at a time (H2, then H3) - screen readers and search engines read them as an outline."):e.headings.length>1&&t("headings","Content","pass","Heading order",`${f(e.headings.length,"heading")}, in order.`),e.words<Math.round(a.minWords/3)?t("words","Content","warn","Length",`${f(e.words,"word")}. Very little for a search engine to go on.`,`Pages that rank usually have ${a.minWords}+ words - unless the page is a form or a contact page.`):e.words<a.minWords?t("words","Content","info","Length",`${f(e.words,"word")}.`,`Pages that rank usually have ${a.minWords}+ words.`):t("words","Content","pass","Length",`${e.words.toLocaleString("en-GB")} words.`);const x=e.images.filter(o=>o.role!=="presentation"),l=x.filter(o=>o.alt===null);l.length?t("alt","Content","fail","Image descriptions",`${f(l.length,"image")} of ${x.length} ${l.length===1?"has":"have"} no alt text.`,'Describe each image in a few words (empty alt="" only for decoration). It is how search and screen readers see them.'):x.length&&t("alt","Content","pass","Image descriptions",`All ${f(x.length,"image")} described.`);const c=e.links.filter(o=>j(o.href,i));!c.length&&e.links.length<50?t("links","Content","warn","Links to your other pages","None in the page itself.","Link to related pages: it helps visitors, and search engines find pages through links."):t("links","Content","pass","Links to your other pages",`${f(c.length,"link")}.`);const h=String(n||"").trim().toLowerCase();if(h){const b=[["title",v(e.title,h)],["description",v(u,h)],["main heading",g.some(S=>v(S.text,h))],["address",T(s).includes(T(h))],["first paragraph",v(e.firstPara,h)]].filter(([,S])=>!S).map(([S])=>S),k=N(e.text,h),L=e.words?k*h.split(/\s+/).length*100/e.words:0;b.length>=3?t("focus","Keyphrase","fail",`"${n}"`,`Missing from the ${b.join(", ")}.`,"Use the phrase people search for where they look first: the title, the heading and the opening."):b.length?t("focus","Keyphrase","warn",`"${n}"`,`Missing from the ${b.join(", ")}.`,"Work it in naturally - never at the cost of reading well."):t("focus","Keyphrase","pass",`"${n}"`,"In the title, description, heading, address and opening."),e.words<100||(L>3?t("density","Keyphrase","warn","Repetition",`The phrase is ${L.toFixed(1)}% of the words (${f(k,"time")}).`,"Over about 3% reads as stuffing - use other words for the same thing."):k&&t("density","Keyphrase","pass","Repetition",`Used ${f(k,"time")} (${L.toFixed(1)}%).`))}else t("focus","Keyphrase","info","Focus keyphrase","None set.","Say which search this page should be found for, and the checks will look for it.");return $?t("index","Technical","info","Search engines","Hidden from search engines (noindex).","On purpose for thank-you pages and the like; otherwise switch it off."):t("index","Technical","pass","Search engines","May be indexed."),e.canonical?e.canonical.replace(/\/$/,"").endsWith(s==="/"?"":s)?t("canonical","Technical","pass","Canonical address",e.canonical):t("canonical","Technical","info","Canonical address",`Points elsewhere: ${e.canonical}.`,"Right when this page is a copy of that one; otherwise it hides this page."):t("canonical","Technical","warn","Canonical address","No canonical link.","Set the site address in Settings so every page names its one true address."),e.og.image?t("share-image","Sharing","pass","Share image",e.og.image):t("share-image","Sharing","warn","Share image","No image when the page is shared.","Choose an image (1200 \xD7 630 is ideal), or set a default one for the site."),e.jsonLdTypes.includes("(invalid)")?t("schema","Technical","fail","Structured data","A JSON-LD block is not valid JSON.","Fix or remove it - search engines ignore the lot."):e.jsonLdTypes.length?t("schema","Technical","pass","Structured data",e.jsonLdTypes.join(", ")):t("schema","Technical","info","Structured data","None.","Structured data lets search engines show rich results."),e.lang||t("lang","Technical","warn","Language","The page does not say what language it is in.",'The site template sets <html lang="\u2026">.'),{...score(p),checks:p}}function j(e,s){const n=String(e||"").trim();if(!n||n.startsWith("#")||/^(mailto|tel|javascript|data):/i.test(n))return!1;if(n.startsWith("/")&&!n.startsWith("//"))return!0;if(!s)return!1;try{return new URL(n).origin===new URL(s).origin}catch{return!1}}function N(e,s){if(!s)return 0;const n=s.replace(/[.*+?^${}()|[\]\\]/g,"\\$&").replace(/\s+/g,"\\s+");return(String(e).match(new RegExp(`(^|[^\\p{L}\\p{N}])${n}(?=$|[^\\p{L}\\p{N}])`,"giu"))||[]).length}export function score(e){const s=e.filter(r=>r.status!=="info"),n=s.reduce((r,i)=>r+(i.status==="pass"?1:i.status==="warn"?.5:0),0);return{score:s.length?Math.round(n/s.length*100):100,failing:e.filter(r=>r.status==="fail").length,warnings:e.filter(r=>r.status==="warn").length}}export function siteIssues(e){const s=n=>{const r=new Map;for(const i of e){const a=String(i[n]||"").trim().toLowerCase();!a||i.noindex||i.error||(r.has(a)||r.set(a,{text:i[n],paths:[]}),r.get(a).paths.push(i.urlPath))}return[...r.values()].filter(i=>i.paths.length>1).sort((i,a)=>a.paths.length-i.paths.length)};return{duplicateTitles:s("title"),duplicateDescriptions:s("description")}}export function snippet({title:e="",description:s="",url:n="",limits:r={}}={}){const i={...LIMITS,...r},a=(t,$)=>t.length>$?`${t.slice(0,$-1).replace(/\s+\S*$/,"")} \u2026`:t;let p=n;try{const t=new URL(n);p=[t.hostname,...t.pathname.split("/").filter(Boolean)].join(" \u203A ")}catch{}return{title:a(String(e),i.titleMax),description:a(String(s),i.descMax),crumbs:p,titleCut:String(e).length>i.titleMax,descriptionCut:String(s).length>i.descMax}}
1
+ export const LIMITS={titleMin:30,titleMax:60,descMin:70,descMax:160,minWords:300};const A={amp:"&",lt:"<",gt:">",quot:'"',apos:"'",nbsp:" ",ndash:"\u2013",mdash:"\u2014",hellip:"\u2026",lsquo:"\u2018",rsquo:"\u2019",ldquo:"\u201C",rdquo:"\u201D",pound:"\xA3",euro:"\u20AC",copy:"\xA9"};export function decode(e){return String(e??"").replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi,(s,n)=>{if(n[0]==="#"){const r=n[1]==="x"||n[1]==="X"?parseInt(n.slice(2),16):parseInt(n.slice(1),10);return Number.isFinite(r)&&r>0&&r<1114112?String.fromCodePoint(r):s}return A[n.toLowerCase()]??s})}const M=e=>decode(String(e??"").replace(/<[^>]*>/g," ")).replace(/\s+/g," ").trim();export function attrs(e){const s={},n=String(e).replace(/^<\s*[a-z0-9-]+/i,"").replace(/\/?>$/,""),r=/([^\s"'=<>`/]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;let i;for(;i=r.exec(n);)s[i[1].toLowerCase()]=decode(i[2]??i[3]??i[4]??"");return s}export function scanHtml(e){const s=String(e??""),n=s.search(/<\/head\s*>/i),r=n>=0?s.slice(0,n):s,i=[];for(const o of s.matchAll(/<script\b[^>]*type\s*=\s*["']?application\/ld\+json["']?[^>]*>([\s\S]*?)<\/script>/gi))try{const c=JSON.parse(o[1]);for(const h of[c,...Array.isArray(c)?c:[],...Array.isArray(c?.["@graph"])?c["@graph"]:[]]){const l=h?.["@type"];l&&i.push(...(Array.isArray(l)?l:[l]).map(String))}}catch{i.push("(invalid)")}const a=s.replace(/<!--[\s\S]*?-->/g," ").replace(/<(script|style|template|noscript|svg)\b[\s\S]*?<\/\1\s*>/gi," ").replace(/<div\b[^>]*\bclass\s*=\s*["'][^"']*\b(?:cms-nojs-links|cms-form-embed-summary)\b[^>]*>[\s\S]*?<\/div\s*>/gi," "),u=[...r.matchAll(/<meta\b[^>]*>/gi)].map(o=>attrs(o[0])),t=(o,c)=>u.find(h=>(h[o]||"").toLowerCase()===c)?.content,$=[...r.matchAll(/<link\b[^>]*>/gi)].map(o=>attrs(o[0])),d=a.search(/<body\b/i),f=d>=0?a.slice(d):a,m=[...f.matchAll(/<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1\s*>/gi)].map(o=>({level:Number(o[1]),text:M(o[2])})),w=[...f.matchAll(/<img\b[^>]*>/gi)].map(o=>{const c=attrs(o[0]);return{src:c.src||c["data-src"]||"",alt:"alt"in c?c.alt:null,role:c.role||""}}),g=[...f.matchAll(/<a\b([^>]*)>([\s\S]*?)<\/a\s*>/gi)].map(o=>{const c=attrs(`<a ${o[1]}>`);return{href:c.href||"",text:M(o[2]),rel:c.rel||"",title:c.title||""}}),y=M(f),b=M(f.match(/<p\b[^>]*>([\s\S]*?)<\/p\s*>/i)?.[1]||"");return{title:M(r.match(/<title\b[^>]*>([\s\S]*?)<\/title\s*>/i)?.[1]||""),description:t("name","description")??null,robots:t("name","robots")||"",viewport:!!t("name","viewport"),canonical:$.find(o=>(o.rel||"").toLowerCase()==="canonical")?.href||"",lang:attrs(s.match(/<html\b[^>]*>/i)?.[0]||"<html>").lang||"",og:{title:t("property","og:title")||"",description:t("property","og:description")||"",image:t("property","og:image")||"",type:t("property","og:type")||""},twitter:{card:t("name","twitter:card")||"",image:t("name","twitter:image")||""},headings:m,images:w,links:g,words:y?y.split(/\s+/).filter(o=>/[\p{L}\p{N}]/u.test(o)).length:0,text:y.slice(0,2e4),firstPara:b.slice(0,1e3),jsonLdTypes:[...new Set(i)],entriesLeftOut:[...s.matchAll(/\bdata-cb-more="(\d+)"/g)].reduce((o,c)=>o+Number(c[1]),0)}}const p=(e,s,n=`${s}s`)=>`${e} ${e===1?s:n}`,v=(e,s)=>!!s&&String(e||"").toLowerCase().includes(s),C=e=>String(e||"").toLowerCase().normalize("NFKD").replace(/[̀-ͯ]/g,"").replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"");export function analyse(e,{urlPath:s="/",focus:n="",limits:r={},origin:i=""}={}){const a={...LIMITS,...r},u=[],t=(l,x,L,k,S,T="")=>u.push({id:l,group:x,status:L,title:k,detail:S,fix:T}),$=/noindex/i.test(e.robots),d=e.title.length;d?d<a.titleMin?t("title","Search result","warn","Title",`${d} characters - short. Search engines may write their own.`,`Aim for ${a.titleMin}-${a.titleMax} characters that say what the page is about.`):d>a.titleMax?t("title","Search result","warn","Title",`${d} characters - it will be cut off after about ${a.titleMax}.`,"Put the words that matter first, or shorten it."):t("title","Search result","pass","Title",`${d} characters.`):t("title","Search result","fail","Title","The page has no title.","Give it one: it is the blue link people click in the results.");const f=e.description,m=(f||"").length;m?m<a.descMin?t("description","Search result","warn","Description",`${m} characters - short.`,`Aim for ${a.descMin}-${a.descMax} characters.`):m>a.descMax?t("description","Search result","warn","Description",`${m} characters - it will be cut off after about ${a.descMax}.`,"Shorten it, keeping the point in the first half."):t("description","Search result","pass","Description",`${m} characters.`):t("description","Search result","fail","Description","No description: search engines pick any sentence from the page.","Write one or two sentences that make someone want to click.");const w=s.split("/").filter(Boolean).pop()||"";s!=="/"&&(w.length>60||/[A-Z_ %]/.test(w)||/\d{5,}/.test(w))?t("url","Search result","warn","Address",`"${w}" is long or hard to read.`,"Short, lower-case words joined by hyphens read best - rename the page (a redirect is added for you)."):t("url","Search result","pass","Address",s==="/"?"The home page.":`"${w}" reads well.`);const g=e.headings.filter(l=>l.level===1);g.length?g.length>1?t("h1","Content","warn","Main heading",`${g.length} H1 headings: "${g.slice(0,3).map(l=>l.text).join('", "')}".`,"Keep one H1; make the others H2."):g[0].text?t("h1","Content","pass","Main heading",`"${g[0].text.slice(0,80)}".`):t("h1","Content","warn","Main heading","The H1 heading is empty.","Give it words."):t("h1","Content","fail","Main heading","There is no H1 heading.","Start the page with one main heading (# in Markdown) saying what it is about.");const y=[];for(let l=1;l<e.headings.length;l++)e.headings[l].level>e.headings[l-1].level+1&&y.push(`H${e.headings[l-1].level} \u2192 H${e.headings[l].level}`);y.length?t("headings","Content","warn","Heading order",`Levels are skipped: ${[...new Set(y)].slice(0,3).join(", ")}.`,"Go down one level at a time (H2, then H3) - screen readers and search engines read them as an outline."):e.headings.length>1&&t("headings","Content","pass","Heading order",`${p(e.headings.length,"heading")}, in order.`),e.words<Math.round(a.minWords/3)?t("words","Content","warn","Length",`${p(e.words,"word")}. Very little for a search engine to go on.`,`Pages that rank usually have ${a.minWords}+ words - unless the page is a form or a contact page.`):e.words<a.minWords?t("words","Content","info","Length",`${p(e.words,"word")}.`,`Pages that rank usually have ${a.minWords}+ words.`):t("words","Content","pass","Length",`${e.words.toLocaleString("en-GB")} words.`);const b=e.images.filter(l=>l.role!=="presentation"),o=b.filter(l=>l.alt===null);o.length?t("alt","Content","fail","Image descriptions",`${p(o.length,"image")} of ${b.length} ${o.length===1?"has":"have"} no alt text.`,'Describe each image in a few words (empty alt="" only for decoration). It is how search and screen readers see them.'):b.length&&t("alt","Content","pass","Image descriptions",`All ${p(b.length,"image")} described.`);const c=e.links.filter(l=>N(l.href,i));!c.length&&e.links.length<50&&t("links","Content","warn","Links to your other pages","None in the page itself.","Link to related pages: it helps visitors, and search engines find pages through links."),e.entriesLeftOut>0?t("collection-cap","Content","warn","Collections",`${p(e.entriesLeftOut,"entry","entries")} left out of the page HTML.`,"Robots that run no JavaScript read the first 1,000 entries of a collection. Split it across pages, or filter each listing."):t("links","Content","pass","Links to your other pages",`${p(c.length,"link")}.`);const h=String(n||"").trim().toLowerCase();if(h){const x=[["title",v(e.title,h)],["description",v(f,h)],["main heading",g.some(S=>v(S.text,h))],["address",C(s).includes(C(h))],["first paragraph",v(e.firstPara,h)]].filter(([,S])=>!S).map(([S])=>S),L=j(e.text,h),k=e.words?L*h.split(/\s+/).length*100/e.words:0;x.length>=3?t("focus","Keyphrase","fail",`"${n}"`,`Missing from the ${x.join(", ")}.`,"Use the phrase people search for where they look first: the title, the heading and the opening."):x.length?t("focus","Keyphrase","warn",`"${n}"`,`Missing from the ${x.join(", ")}.`,"Work it in naturally - never at the cost of reading well."):t("focus","Keyphrase","pass",`"${n}"`,"In the title, description, heading, address and opening."),e.words<100||(k>3?t("density","Keyphrase","warn","Repetition",`The phrase is ${k.toFixed(1)}% of the words (${p(L,"time")}).`,"Over about 3% reads as stuffing - use other words for the same thing."):L&&t("density","Keyphrase","pass","Repetition",`Used ${p(L,"time")} (${k.toFixed(1)}%).`))}else t("focus","Keyphrase","info","Focus keyphrase","None set.","Say which search this page should be found for, and the checks will look for it.");return $?t("index","Technical","info","Search engines","Hidden from search engines (noindex).","On purpose for thank-you pages and the like; otherwise switch it off."):t("index","Technical","pass","Search engines","May be indexed."),e.canonical?e.canonical.replace(/\/$/,"").endsWith(s==="/"?"":s)?t("canonical","Technical","pass","Canonical address",e.canonical):t("canonical","Technical","info","Canonical address",`Points elsewhere: ${e.canonical}.`,"Right when this page is a copy of that one; otherwise it hides this page."):t("canonical","Technical","warn","Canonical address","No canonical link.","Set the site address in Settings so every page names its one true address."),e.og.image?t("share-image","Sharing","pass","Share image",e.og.image):t("share-image","Sharing","warn","Share image","No image when the page is shared.","Choose an image (1200 \xD7 630 is ideal), or set a default one for the site."),e.jsonLdTypes.includes("(invalid)")?t("schema","Technical","fail","Structured data","A JSON-LD block is not valid JSON.","Fix or remove it - search engines ignore the lot."):e.jsonLdTypes.length?t("schema","Technical","pass","Structured data",e.jsonLdTypes.join(", ")):t("schema","Technical","info","Structured data","None.","Structured data lets search engines show rich results."),e.lang||t("lang","Technical","warn","Language","The page does not say what language it is in.",'The site template sets <html lang="\u2026">.'),{...score(u),checks:u}}function N(e,s){const n=String(e||"").trim();if(!n||n.startsWith("#")||/^(mailto|tel|javascript|data):/i.test(n))return!1;if(n.startsWith("/")&&!n.startsWith("//"))return!0;if(!s)return!1;try{return new URL(n).origin===new URL(s).origin}catch{return!1}}function j(e,s){if(!s)return 0;const n=s.replace(/[.*+?^${}()|[\]\\]/g,"\\$&").replace(/\s+/g,"\\s+");return(String(e).match(new RegExp(`(^|[^\\p{L}\\p{N}])${n}(?=$|[^\\p{L}\\p{N}])`,"giu"))||[]).length}export function score(e){const s=e.filter(r=>r.status!=="info"),n=s.reduce((r,i)=>r+(i.status==="pass"?1:i.status==="warn"?.5:0),0);return{score:s.length?Math.round(n/s.length*100):100,failing:e.filter(r=>r.status==="fail").length,warnings:e.filter(r=>r.status==="warn").length}}export function siteIssues(e){const s=n=>{const r=new Map;for(const i of e){const a=String(i[n]||"").trim().toLowerCase();!a||i.noindex||i.error||(r.has(a)||r.set(a,{text:i[n],paths:[]}),r.get(a).paths.push(i.urlPath))}return[...r.values()].filter(i=>i.paths.length>1).sort((i,a)=>a.paths.length-i.paths.length)};return{duplicateTitles:s("title"),duplicateDescriptions:s("description")}}export function snippet({title:e="",description:s="",url:n="",limits:r={}}={}){const i={...LIMITS,...r},a=(t,$)=>t.length>$?`${t.slice(0,$-1).replace(/\s+\S*$/,"")} \u2026`:t;let u=n;try{const t=new URL(n);u=[t.hostname,...t.pathname.split("/").filter(Boolean)].join(" \u203A ")}catch{}return{title:a(String(e),i.titleMax),description:a(String(s),i.descMax),crumbs:u,titleCut:String(e).length>i.titleMax,descriptionCut:String(s).length>i.descMax}}
@@ -60,9 +60,9 @@
60
60
  <li><strong>Form questions</strong> - the form's title and description are in the page, but the
61
61
  questions appear once the page has loaded. Say what the form is for in the text around it
62
62
  too.</li>
63
- <li><strong>Large interactive collections</strong> - a collection set to load its pages from the
64
- server (<code>mode="server"</code>, or one too big to send whole) sends its first page of
65
- entries; the rest arrive as the visitor pages through.</li>
63
+ <li><strong>Very large collections</strong> - every entry of an interactive collection is in
64
+ the page, up to 1,000. Past that, the SEO audit warns about the page: split the collection
65
+ across pages, or filter each listing.</li>
66
66
  <li><strong>Site search results</strong>, <strong>the Calendar's month view</strong> and other
67
67
  Tool screens that load live data.</li>
68
68
  </ul>
@@ -386,7 +386,7 @@ headings (Layouts → Page contents settings, and `[contents]` per page).
386
386
  - **llms.txt** - an automatic `/llms.txt` (the llmstxt.org format) for AI assistants: `# Site title`, `> description` (Settings > SEO default description), then `## Pages` and one section per plugin sitemap source (blog posts and so on), each line `- [Title](absolute url): description`. Built from the same published, public, indexable set as the sitemap (SEO exclusions included), with absolute links from the site address in Site Settings (else the request's origin). Cached and invalidated with the sitemap and with Site Settings saves. On by default with its own switch in SEO settings; answers 404 while "Ask every search engine to stay away" is on. With the SEO Tool switched off it is still served, as the sitemap is.
387
387
  - Where: `/llms.txt`; **Tools > SEO > Settings** ("Publish /llms.txt for AI assistants"); `config/seo.json` `llms.enabled`; `buildLlmsTxt()` in `server/services/sitemap.js`
388
388
  - Since: 1.8.36
389
- - **Readable without JavaScript** - pages are rendered on the server, so robots that run no JavaScript (GPTBot, ClaudeBot and most AI crawlers) read the words, headings, body links, meta tags and JSON-LD. The navbar and footer (menus with their nested items, copyright, social links) are also written as plain links inside `#site-navbar` and `#site-footer`, built from the same per-viewer menu data the browser gets and visually hidden (`.sr-only.cms-nojs-links`, so nothing flashes or shifts); site.js removes them once it has drawn the real ones. An interactive `[collection]` in client mode lists every entry in its no-JS fallback (or its `limit`, at most 200; the rest stay in the browser's payload), an interactive `[view]` has the same fallback, and a `[form]` embed carries the form's title and description as hidden text. Scripts load at the end of the body; the ones nothing needs mid-parse carry `defer` (collection-browser, form-logic-engine, effects, search, analytics, back-to-top, cookie consent - the first two sit above site.js because deferred and module scripts run in one queue in document order). DOMPurify ships in `public/js/vendor/dompurify/` at a pinned version (admin and public pages), so no public page script comes from a CDN. Still built in the browser: form fields, a server-mode collection browser beyond its first page (no page links without JavaScript), search results and the Calendar month view.
389
+ - **Readable without JavaScript** - pages are rendered on the server, so robots that run no JavaScript (GPTBot, ClaudeBot and most AI crawlers) read the words, headings, body links, meta tags and JSON-LD. The navbar and footer (menus with their nested items, copyright, social links) are also written as plain links inside `#site-navbar` and `#site-footer`, built from the same per-viewer menu data the browser gets and visually hidden (`.sr-only.cms-nojs-links`, so nothing flashes or shifts); site.js removes them once it has drawn the real ones. An interactive `[collection]` or `[view]` lists every entry in its no-JS fallback, in client and server mode alike (or its `limit`; at most 1,000 - `FALLBACK_MAX` - with the count left out in `data-cb-more`, which the SEO audit warns about; server mode runs one extra query for it, kept by the page cache), and a `[form]` embed carries the form's title and description as hidden text. Scripts load at the end of the body; the ones nothing needs mid-parse carry `defer` (collection-browser, form-logic-engine, effects, search, analytics, back-to-top, cookie consent - the first two sit above site.js because deferred and module scripts run in one queue in document order). DOMPurify ships in `public/js/vendor/dompurify/` at a pinned version (admin and public pages), so no public page script comes from a CDN. Still built in the browser: form fields, search results and the Calendar month view.
390
390
  - Where: `server/templates/page.html`, `server/services/renderer.js`, `server/services/nojsLinks.js`, `server/services/markdown.js`; checked by `tests/services/page-template-scripts.test.js`, `tests/services/nojs-links.test.js`, `tests/markdown/nojs-fallbacks.test.js`; admin **Documentation > Usage > Search Engines & AI**
391
391
  - Since: 1.8.36
392
392
  - **SEO tool** - an audit of every sitemap URL, fetched in-process and scored (title and description length, single H1, heading order, word count, image alt text, internal links, focus keyphrase placement and density, noindex, canonical, share image, structured data, language), with site-wide duplicate title/description detection and a scheduled re-audit. Open a page to see its search result and share card as they will look and edit its fields. **Redirects** tab (301, 302, 307, 308, 410; CSV import; hit counts; automatic redirect on page rename). **Sitemap** tab shows what is listed and left out, plus robots.txt. Organisation / LocalBusiness / Person structured data for the home page and BlogPosting for blog posts. Can be switched off as a Tool; SEO Pro plugs into its registry.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "1.8.39",
3
+ "version": "1.8.40",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",
@@ -1363,6 +1363,7 @@ async function renderCollectionBrowserShell(attrs, {extraFilter = null, scope =
1363
1363
  let entries = [];
1364
1364
  let total = 0;
1365
1365
  let mode = declaredMode;
1366
+ let fallbackEntries = [];
1366
1367
 
1367
1368
  try {
1368
1369
  schema = await getCollection(slug);
@@ -1405,11 +1406,21 @@ async function renderCollectionBrowserShell(attrs, {extraFilter = null, scope =
1405
1406
  entries = r.entries;
1406
1407
  total = r.total;
1407
1408
  }
1409
+
1410
+ // No-JS fallback: every entry, whatever the mode, up to the author's
1411
+ // `limit` and FALLBACK_MAX. Client mode already holds them; server
1412
+ // mode (one page) asks once more. The page cache keeps the cost to
1413
+ // one query per render.
1414
+ const want = fallbackWant(attrs, total);
1415
+ fallbackEntries = want > entries.length
1416
+ ? (await listEntries(slug, {...seedOpts, page: 1, limit: want})).entries
1417
+ : entries.slice(0, want);
1408
1418
  } catch {
1409
1419
  // Slug unknown / read error - emit an empty shell so the page still renders.
1410
1420
  schema = {fields: [], title: attrs.slug};
1411
1421
  entries = [];
1412
1422
  total = 0;
1423
+ fallbackEntries = [];
1413
1424
  }
1414
1425
 
1415
1426
  const config = {
@@ -1451,16 +1462,14 @@ async function renderCollectionBrowserShell(attrs, {extraFilter = null, scope =
1451
1462
  syncUrl: !isTruthyAttr(attrs['no-url-sync']) // default ON
1452
1463
  };
1453
1464
 
1454
- // No-JS fallback, drawn by the static renderer in the browser's display
1455
- // from the entries already fetched for the payload (no second query).
1456
- // Client mode ships every entry in data-cb-data, so the fallback lists
1457
- // them too, up to the author's `limit` and FALLBACK_MAX - a robot that
1458
- // runs no JavaScript reads the collection, not just page one, and a huge
1459
- // one does not double the page. Server mode ships one page: that page.
1465
+ // No-JS fallback, drawn by the static renderer in the browser's display,
1466
+ // so a robot that runs no JavaScript reads the whole collection, not just
1467
+ // page one. Entries past FALLBACK_MAX are counted in data-cb-more for the
1468
+ // SEO audit to report.
1460
1469
  const fallback = await renderCollectionFragment({...attrs, display: config.display}, {
1461
- extraFilter, scope,
1462
- entries: entries.slice(0, fallbackLimit(attrs, entries.length))
1470
+ extraFilter, scope, entries: fallbackEntries
1463
1471
  });
1472
+ const more = fallbackOwed(attrs, total) - fallbackEntries.length;
1464
1473
 
1465
1474
  // Encode payloads. Schema can be modest; data can be large in client mode,
1466
1475
  // so we base64 once each and let the browser decode.
@@ -1473,7 +1482,7 @@ async function renderCollectionBrowserShell(attrs, {extraFilter = null, scope =
1473
1482
  `data-cb-config="${enc(config)}" ` +
1474
1483
  `data-cb-schema="${enc({fields: schema?.fields || [], title: schema?.title || ''})}" ` +
1475
1484
  `data-cb-data="${enc({entries, total, page: 1, pageSize})}">` +
1476
- `<div class="dm-cb-fallback">${fallback}</div>` +
1485
+ fallbackDiv(fallback, more) +
1477
1486
  `</div>`;
1478
1487
  }
1479
1488
 
@@ -1505,6 +1514,7 @@ async function renderViewBrowserShell(attrs, {viewConfig = null, allowed = false
1505
1514
 
1506
1515
  let entries = [];
1507
1516
  let total = 0;
1517
+ let fallbackEntries = [];
1508
1518
 
1509
1519
  if (viewConfig && allowed) {
1510
1520
  try {
@@ -1514,6 +1524,14 @@ async function renderViewBrowserShell(attrs, {viewConfig = null, allowed = false
1514
1524
  const r = await executeViewConfig(viewConfig, {page: 1, limit, user});
1515
1525
  entries = (r.results || []).map(doc => ({id: doc.id || '', data: doc.data || doc}));
1516
1526
  total = r.total ?? entries.length;
1527
+ // No-JS fallback: every row up to FALLBACK_MAX, as for collections.
1528
+ const want = fallbackWant(attrs, total);
1529
+ if (want > entries.length) {
1530
+ const all = await executeViewConfig(viewConfig, {page: 1, limit: want, user});
1531
+ fallbackEntries = (all.results || []).map(doc => ({id: doc.id || '', data: doc.data || doc}));
1532
+ } else {
1533
+ fallbackEntries = entries.slice(0, want);
1534
+ }
1517
1535
  } catch {
1518
1536
  // Pipeline error - emit empty shell.
1519
1537
  }
@@ -1570,14 +1588,13 @@ async function renderViewBrowserShell(attrs, {viewConfig = null, allowed = false
1570
1588
  sourceKind: 'view' // hint for client-side: server-mode would need a different fetch endpoint
1571
1589
  };
1572
1590
 
1573
- // No-JS fallback: the rows the browser was handed (all of them in client
1574
- // mode, the first page in server mode), drawn by the static [view]
1575
- // renderer. A refused viewer has no rows and gets the empty state.
1591
+ // No-JS fallback: every row (up to FALLBACK_MAX), drawn by the static
1592
+ // [view] renderer. A refused viewer has no rows and gets the empty state.
1576
1593
  let fallback = '';
1577
1594
  try {
1578
1595
  fallback = viewConfig && allowed
1579
1596
  ? await renderViewEntries(slug, {...attrs, display: config.display},
1580
- viewConfig, entries.slice(0, fallbackLimit(attrs, entries.length)))
1597
+ viewConfig, fallbackEntries)
1581
1598
  : `<div class="dm-collection-display dm-collection-empty"><p>${escapeHtmlText(config.emptyMsg)}</p></div>`;
1582
1599
  } catch {
1583
1600
  // A fallback that cannot be drawn must not take the browser with it.
@@ -1591,26 +1608,35 @@ async function renderViewBrowserShell(attrs, {viewConfig = null, allowed = false
1591
1608
  `data-cb-config="${enc(config)}" ` +
1592
1609
  `data-cb-schema="${enc({fields: schemaFields, title: viewConfig?.title || slug})}" ` +
1593
1610
  `data-cb-data="${enc({entries, total, page: 1, pageSize})}">` +
1594
- `<div class="dm-cb-fallback">${fallback}</div>` +
1611
+ fallbackDiv(fallback, fallbackOwed(attrs, total) - fallbackEntries.length) +
1595
1612
  `</div>`;
1596
1613
  }
1597
1614
 
1598
1615
  /**
1599
- * How many entries an interactive browser's no-JS fallback draws: the
1600
- * author's `limit` when set, never more than the browser itself holds, and
1601
- * never more than FALLBACK_MAX.
1616
+ * How many entries an interactive browser's no-JS fallback owes a robot: all
1617
+ * of them, or the author's `limit` when set.
1602
1618
  *
1603
1619
  * @param {Record<string, string>} attrs
1604
- * @param {number} held - entries the browser ships (all of them in client mode, one page in server mode)
1620
+ * @param {number} total - entries matching the listing
1605
1621
  * @returns {number}
1606
1622
  */
1607
- function fallbackLimit(attrs, held) {
1623
+ function fallbackOwed(attrs, total) {
1608
1624
  const limit = parseInt(attrs.limit, 10);
1609
- return Math.min(limit > 0 ? limit : held, held, FALLBACK_MAX);
1625
+ return Math.min(limit > 0 ? limit : total, total);
1626
+ }
1627
+
1628
+ /** How many of those it draws: never more than FALLBACK_MAX. */
1629
+ function fallbackWant(attrs, total) {
1630
+ return Math.min(fallbackOwed(attrs, total), FALLBACK_MAX);
1631
+ }
1632
+
1633
+ /** The fallback wrapper; `more` (entries left out) feeds the SEO audit. */
1634
+ function fallbackDiv(inner, more) {
1635
+ return `<div class="dm-cb-fallback"${more > 0 ? ` data-cb-more="${more}"` : ''}>${inner}</div>`;
1610
1636
  }
1611
1637
 
1612
- /** The most entries a browser's no-JS fallback draws; the rest stay in the payload for the browser. */
1613
- export const FALLBACK_MAX = 200;
1638
+ /** The most entries a browser's no-JS fallback draws; past it, the SEO audit warns. */
1639
+ export const FALLBACK_MAX = 1000;
1614
1640
 
1615
1641
  /**
1616
1642
  * Render a `[collection]` block's HTML fragment from parsed attributes.