domma-cms 1.8.39 → 1.8.41
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 +8 -0
- package/admin/js/lib/seo-analyse.js +1 -1
- package/admin/js/templates/docs/usage-search-engines.html +3 -3
- package/docs/DommaFeatures.md +2 -1
- package/docs/configuration.md +1 -0
- package/package.json +1 -1
- package/server/server.js +3 -2
- package/server/services/embedCsp.js +19 -0
- package/server/services/markdown.js +48 -22
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.8.41] - 2026-10-23
|
|
4
|
+
|
|
5
|
+
- **A page can show images from another site.** `csp.imgHosts` in `config/server.json` adds hosts to the Content-Security-Policy's `img-src` (`imageSources()` in `server/services/embedCsp.js`, beside `connectSources()`; bare hostnames, https only). The policy allowed only the site's own images, so an `<img>` from anywhere else - a "Featured on" badge, say - was blocked.
|
|
6
|
+
|
|
7
|
+
## [1.8.40] - 2026-10-22
|
|
8
|
+
|
|
9
|
+
- **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.
|
|
10
|
+
|
|
3
11
|
## [1.8.39] - 2026-10-22
|
|
4
12
|
|
|
5
13
|
- **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>
|
|
64
|
-
|
|
65
|
-
|
|
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>
|
package/docs/DommaFeatures.md
CHANGED
|
@@ -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]`
|
|
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.
|
|
@@ -1334,6 +1334,7 @@ The four Tools below were plugins until 0.67.0 and are now part of the CMS. They
|
|
|
1334
1334
|
- **`TRUST_PROXY`** - Takes Fastify's own values: `loopback` (recommended behind Apache or the manager, and the default for new sites the manager creates), a list of addresses or CIDR ranges, or a hop count. `1` or `true` trusts every hop, which lets a visitor spoof their IP address.
|
|
1335
1335
|
Where: `server/services/network.js` - Since: 0.83.0.
|
|
1336
1336
|
- **Reading other sites from a page** - `csp.connectHosts` in `config/server.json` lets public pages fetch from the listed hosts (added to the CSP's `connect-src`, https only); domma-cms.com's Pricing page reads the Pro Tool prices from dcm this way.
|
|
1337
|
+
- **Showing images from other sites** - `csp.imgHosts` in `config/server.json` lets public pages show images from the listed hosts (added to the CSP's `img-src`, https only); without it only the site's own images load.
|
|
1337
1338
|
- **Public asset CORS** - `cors.publicPrefixes` in `config/server.json` adds `Access-Control-Allow-Origin: *` to GET and HEAD requests under the listed paths only, so other sites can import scripts or assets from them.
|
|
1338
1339
|
Where: `server/services/publicCors.js`.
|
|
1339
1340
|
- **Email service** - A Nodemailer transport built from the SMTP settings in `site.json`, used for form emails, password resets and notices. With no SMTP host set, mail goes to a throwaway Ethereal test inbox, which reports success but delivers to nobody. The CMS makes this visible in Settings, in health checks and with an "Email not set up" notification. New sites ship with an SMTP host, defaulting to a local mail server on port 25 or taken from the environment.
|
package/docs/configuration.md
CHANGED
|
@@ -27,6 +27,7 @@ Controls the HTTP server.
|
|
|
27
27
|
| `cors.origin` | boolean / string / string[] | `false` | Passed directly to `@fastify/cors`. `false` disables CORS. Set to `"*"` or a domain to allow cross-origin requests |
|
|
28
28
|
| `cors.publicPrefixes` | string[] | `[]` | Absolute path prefixes whose **GET and HEAD** responses carry `Access-Control-Allow-Origin: *`, so other origins can hot-link those assets. A module script is fetched in CORS mode, so this is what makes e.g. `/public/demos/` importable from elsewhere. `@fastify/cors` decides per origin and cannot express a path, hence the separate key. Reads only - it never widens a write, and it applies to listed prefixes only. A value that is not an absolute path is ignored |
|
|
29
29
|
| `csp.connectHosts` | string[] | `[]` | Other sites a public page may read from in the browser (`fetch`), added to the Content-Security-Policy's `connect-src` as `https://<host>`. The site itself and cdn.jsdelivr.net are always allowed. Bare hostnames only (`dcm.dommajs.org`); a scheme, path, port or wildcard is ignored. Read at boot, so restart after changing it |
|
|
30
|
+
| `csp.imgHosts` | string[] | `[]` | Other sites a public page may show images from (an `<img>` hosted elsewhere, such as a badge or partner logo), added to the Content-Security-Policy's `img-src` as `https://<host>`. The site itself, `data:` and `blob:` are always allowed; without this an image from another site is blocked - upload it to Media instead if you can. Bare hostnames only (`statics.startupbase.io`); a scheme, path, port or wildcard is ignored. Read at boot, so restart after changing it |
|
|
30
31
|
| `uploads.maxFileSize` | number | `10485760` (10 MB) | Maximum allowed upload size in bytes |
|
|
31
32
|
| `logging.level` | string | Log verbosity: `fatal`, `error`, `warn`, `info`, `debug`, `trace`, `silent`. Defaults to `info` in development, `warn` otherwise. `warn` hides 4xx responses and plugin `log.info` output, so a failed admin action leaves no trace - raise it to `info` if you need to diagnose one. Overridden by the `LOG_LEVEL` env var. |
|
|
32
33
|
| `restartOnPluginToggle` | boolean | `true` | Restart the server automatically when an admin enables or disables a plugin. See **Plugin toggles restart the server** below |
|
package/package.json
CHANGED
package/server/server.js
CHANGED
|
@@ -33,7 +33,7 @@ import * as cache from './services/cache/index.js';
|
|
|
33
33
|
import {parseTrustProxy} from './services/network.js';
|
|
34
34
|
import {requestSerializer} from './services/request-log.js';
|
|
35
35
|
import {rateLimitOptions} from './services/rate-limit.js';
|
|
36
|
-
import {connectSources, frameSources} from './services/embedCsp.js';
|
|
36
|
+
import {connectSources, frameSources, imageSources} from './services/embedCsp.js';
|
|
37
37
|
import {cspSources as gaSources} from './services/googleAnalytics.js';
|
|
38
38
|
|
|
39
39
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -128,7 +128,8 @@ await app.register(helmet, {
|
|
|
128
128
|
// (services/googleAnalytics.js) - a site without one keeps exactly this policy.
|
|
129
129
|
scriptSrc: ["'self'", "'unsafe-inline'", "'wasm-unsafe-eval'", 'https://cdn.jsdelivr.net', gaSources('script')],
|
|
130
130
|
styleSrc: ["'self'", "'unsafe-inline'", 'https://cdn.jsdelivr.net', 'https://fonts.googleapis.com'],
|
|
131
|
-
|
|
131
|
+
// config/server.json csp.imgHosts: other sites a page may show images from (services/embedCsp.js).
|
|
132
|
+
imgSrc: [...imageSources(serverConfig.csp?.imgHosts), gaSources('img')],
|
|
132
133
|
fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com'],
|
|
133
134
|
// config/server.json csp.connectHosts: other sites a page may read from (services/embedCsp.js).
|
|
134
135
|
connectSrc: [...connectSources(serverConfig.csp?.connectHosts), gaSources('connect')],
|
|
@@ -50,3 +50,22 @@ export function connectSources(connectHosts = []) {
|
|
|
50
50
|
.map(h => `https://${h}`);
|
|
51
51
|
return [...new Set([...ALWAYS_CONNECTED, ...extra])];
|
|
52
52
|
}
|
|
53
|
+
|
|
54
|
+
/** What a page may always show images from: the site itself, and inline/data and blob images. */
|
|
55
|
+
export const ALWAYS_IMAGED = ["'self'", 'data:', 'blob:'];
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The img-src: the site, data: and blob:, and every host in config/server.json `csp.imgHosts` - for a page
|
|
59
|
+
* that shows an image hosted elsewhere (a badge, a partner logo). Bare hostnames only, served over https;
|
|
60
|
+
* anything else is dropped. Read at boot, like connect-src.
|
|
61
|
+
*
|
|
62
|
+
* @param {string[]} imgHosts - config/server.json csp.imgHosts
|
|
63
|
+
* @returns {string[]} img-src sources
|
|
64
|
+
*/
|
|
65
|
+
export function imageSources(imgHosts = []) {
|
|
66
|
+
const extra = (Array.isArray(imgHosts) ? imgHosts : [])
|
|
67
|
+
.map(h => String(h).trim().toLowerCase())
|
|
68
|
+
.filter(h => HOST_RE.test(h))
|
|
69
|
+
.map(h => `https://${h}`);
|
|
70
|
+
return [...new Set([...ALWAYS_IMAGED, ...extra])];
|
|
71
|
+
}
|
|
@@ -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
|
-
//
|
|
1456
|
-
//
|
|
1457
|
-
//
|
|
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
|
-
|
|
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:
|
|
1574
|
-
//
|
|
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,
|
|
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
|
-
|
|
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
|
|
1600
|
-
* author's `limit` when set
|
|
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}
|
|
1620
|
+
* @param {number} total - entries matching the listing
|
|
1605
1621
|
* @returns {number}
|
|
1606
1622
|
*/
|
|
1607
|
-
function
|
|
1623
|
+
function fallbackOwed(attrs, total) {
|
|
1608
1624
|
const limit = parseInt(attrs.limit, 10);
|
|
1609
|
-
return Math.min(limit > 0 ? limit :
|
|
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;
|
|
1613
|
-
export const FALLBACK_MAX =
|
|
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.
|