@fulldecent/nice-checkers-plugin 1.3.7 → 1.3.8

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/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {Rule}from'html-validate';import {execSync}from'child_process';import {quote}from'shell-quote';import*as re from'cheerio';import g from'fs';import u from'path';import ge from'better-sqlite3';var ne=Object.defineProperty;var m=(f,a)=>ne(f,"name",{value:a,configurable:true});var G=class G extends Rule{documentation(){return {description:'Requires <link rel="alternate" hreflang="..."> to use fully qualified URLs with protocol (https://).',url:"https://github.com/fulldecent/html-validate-nice-checkers/blob/main/README.md#rules"}}setup(){this.on("dom:ready",a=>{this.domReady(a);});}domReady(a){let{document:e}=a,s=e.querySelectorAll('link[rel="alternate"][hreflang]');for(let t of s){let r=t.getAttribute("href")?.value;typeof r!="string"||!r||r.includes("://")||this.report({node:t,message:`Alternate language link must use a fully qualified URL (with protocol://), found: ${r}`});}}};m(G,"AlternateLanguageUrlRule");var b=G;function v(f,a={}){let{timeoutSeconds:e=5,userAgent:s="Mozilla/5.0 (compatible; html-validate-nice-checkers)",headOnly:t=false,maxRedirs:r=0,failOnError:i=false,headers:o={}}=a,c=quote([f]),n=["curl","--silent","--max-time",String(e),"--max-redirs",String(r),"--user-agent",quote([s])];t&&n.push("--head"),i&&n.push("--fail");for(let[d,h]of Object.entries(o))n.push("--header",quote([`${d}: ${h}`]));n.push("--dump-header","-"),t&&n.push("--output","/dev/null"),n.push(c);let l=n.join(" ")+" || true";try{let d=execSync(l).toString(),h=d.match(/^HTTP\/[0-9.]+ (\d{3})/m),p=h?.[1]?parseInt(h[1],10):void 0,V=d.match(/^Location: (.+)/im)?.[1]?.trim(),y=d.split(/\r?\n\r?\n/),se=y[0]||"",M=y.slice(1).join(`
2
2
 
3
- `);return {success:p!==void 0&&p>=200&&p<400,statusCode:p,headers:se,body:t?void 0:M,redirectTo:V}}catch(d){return {success:false,error:d instanceof Error?d.message:String(d)}}}m(v,"syncFetch");function D(f,a={}){return v(f,{...a,headOnly:true})}m(D,"syncHead");function L(f){return f.map(a=>{try{return {regex:new RegExp(a.pattern),replacement:a.replacement}}catch{return null}}).filter(a=>a!==null)}m(L,"compileUrlRewrites");function B(f,a){let e=f;for(let s of a)e=e.replace(s.regex,s.replacement);return e}m(B,"applyUrlRewrites");var F={type:"array",items:{type:"object",properties:{pattern:{type:"string"},replacement:{type:"string"}}},description:"Regex rewrite rules applied to URLs before local file resolution. Rewrites that produce a local path are checked on disk."};function N(f,a,e){let s=[f];if(!u.extname(f)){for(let t of a)s.push(`${f}${t}`);s.push(u.join(f,e));}return s}m(N,"getLocalFileCandidates");function W(f){for(let a of f)try{if(g.existsSync(a)&&g.lstatSync(a).isFile())return a}catch{}return null}m(W,"resolveLocalFile");var fe={urlRewrites:[]},j=class j extends Rule{constructor(a){super({...fe,...a}),this.compiledUrlRewrites=L(this.options.urlRewrites);}static schema(){return {urlRewrites:{...F,description:"Regex rewrite rules mapping alternate URLs to local file paths for reciprocal checks."}}}documentation(){return {description:'Enforces best practices for alternate language links (rel="alternate" hreflang) in the <head> of HTML documents.',url:"https://developers.google.com/search/docs/specialty/international/localized-versions"}}setup(){this.on("dom:ready",a=>this.domReady(a));}loadLocalHtml(a){let e;if(a.startsWith("file://"))try{e=new URL(a).pathname;}catch{return null}else e=u.isAbsolute(a)?a:u.resolve(a);let s=N(e,[".html"],"index.html"),t=W(s);return t?g.readFileSync(t,"utf8"):null}fetchHtml(a){let e=B(a,this.compiledUrlRewrites);if(!/^https?:\/\//.test(e))return this.loadLocalHtml(e);let s=v(e,{timeoutSeconds:10,maxRedirs:5});return !s.success||!s.body?null:s.body}domReady(a){let{document:e}=a,s=e.querySelector("html");s&&this.checkAlternateLanguageLinks(s);}checkAlternateLanguageLinks(a){let e=a.querySelector("head");if(!e)return;let s=e.querySelectorAll('link[rel="alternate"][hreflang]');if(s.length===0)return;let t=e.querySelector('link[rel="canonical"]');if(!t)return;let r=t.getAttribute("href")?.value;if(!r||typeof r!="string")return;let i=r,o=a.getAttribute("lang")?.value,c=false;for(let n of s){let l=n.getAttribute("href")?.value,d=typeof l=="string"?l:void 0,h=n.getAttribute("hreflang")?.value;d===i&&(c=true,o&&h!==o&&this.report({node:n,message:`Self alternate link hreflang (${h}) does not match page lang (${o})`})),(!d||!/^https?:\/\//.test(d))&&this.report({node:n,message:`Alternate link href must be fully qualified: ${d??"(empty)"}`});}c||this.report({node:e,message:"No self-link found in alternate language links (must match canonical URL)"});for(let n of s){let l=n.getAttribute("href")?.value,d=typeof l=="string"?l:void 0;if(!d||d===i)continue;let h=this.fetchHtml(d);if(!h){this.report({node:n,message:`Alternate page ${d} is not accessible`});continue}let p=o??"",R=re.load(h),V=R('link[rel="alternate"][hreflang]'),y=false;V.each((se,M)=>{let oe=R(M).attr("href"),ce=R(M).attr("hreflang");if(oe===i&&ce===p)return y=true,false}),y||this.report({node:n,message:`Reciprocal alternate link not found on ${d} (must link back to ${i} with hreflang="${p}")`});}}};m(j,"AlternateLanguageLinksRule");var P=j;var q=class q extends Rule{documentation(){return {description:"Requires a <link rel='canonical'> in the <head> with a specific href format.",url:"https://github.com/fulldecent/html-validate-nice-checkers/blob/main/README.md#rules"}}setup(){this.on("dom:ready",a=>{this.domReady(a);});}domReady(a){let{document:e}=a,s=e.querySelector('head > link[rel="canonical"]');if(s===null){this.report({node:e.querySelector("head"),message:'<head> is missing <link rel="canonical" ...>'});return}let t=s.getAttribute("href")?.value;if(typeof t!="string"||!t){this.report({node:s,message:"<link rel='canonical'> is missing a non-empty href attribute"});return}/\.\w+$/.test(t)&&this.report({node:s,message:"Canonical link href should be extensionless (no .html, .php, etc.)"}),t.toLowerCase().endsWith("/index")&&this.report({node:s,message:'Canonical link href should be "/" instead of ending with "/index"'});}};m(q,"CanonicalLinkRule");var I=q;var ye={proxyUrl:"",skipRegexes:[],cacheExpiryFoundSeconds:720*60*60,cacheExpiryNotFoundSeconds:4320*60,timeoutSeconds:5,cacheDatabasePath:"cache/external-links.db",userAgent:"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.9999.999 Safari/537.36",manuallyReviewedPath:"",manuallyReviewedExpirySeconds:365*24*60*60,urlRewrites:[],alternativeExtensions:[".html"],indexFile:"index.html"};function U(f){try{let a=new URL(f);return a.hostname=a.hostname.toLowerCase(),a.toString()}catch{return f}}m(U,"normalizeUrl");var J=class J extends Rule{constructor(a){super({...ye,...a}),this.skipRegexesCompiled=this.compileRegexes(this.options.skipRegexes),this.manuallyReviewedUrls=this.loadManuallyReviewedUrls(),this.compiledUrlRewrites=L(this.options.urlRewrites??[]);}static schema(){return {proxyUrl:{type:"string",description:"URL of a proxy server to check external links."},skipRegexes:{type:"array",items:{type:"string"},description:"An array of regex patterns (as strings) for URLs to skip."},cacheExpiryFoundSeconds:{type:"number",description:"Number of seconds to cache successful (2xx) responses."},cacheExpiryNotFoundSeconds:{type:"number",description:"Number of seconds to cache failed (non-2xx) responses."},timeoutSeconds:{type:"number",description:"Maximum time in seconds to wait for a response."},cacheDatabasePath:{type:"string",description:"File path for the SQLite cache database."},userAgent:{type:"string",description:"User-Agent string to use for requests."},manuallyReviewedPath:{type:"string",description:"Path to CSV file containing manually reviewed URLs with approval timestamps."},manuallyReviewedExpirySeconds:{type:"number",description:"Number of seconds before a manually reviewed URL approval expires (default: 365 days)."},urlRewrites:{...F,description:"Regex rewrite rules mapping absolute https:// URLs to local paths. If a rewrite produces a local path, the file is checked on disk instead of over the network."},alternativeExtensions:{type:"array",items:{type:"string"},description:'Extensions to try when a rewritten local path has no extension (e.g. [".html"]).'},indexFile:{type:"string",description:'Filename to look for when a rewritten local path resolves to a directory (e.g. "index.html").'}}}documentation(){return {description:"Require all external links to be live.",url:"https://github.com/fulldecent/html-validate-nice-checkers/blob/main/README.md#rules"}}setup(){this.db=this.setupDatabase(),this.on("tag:ready",a=>this.tagReady(a));}setupDatabase(){let a=u.dirname(this.options.cacheDatabasePath);g.mkdirSync(a,{recursive:true});let e=new ge(this.options.cacheDatabasePath);return e.exec(`
3
+ `);return {success:p!==void 0&&p>=200&&p<400,statusCode:p,headers:se,body:t?void 0:M,redirectTo:V}}catch(d){return {success:false,error:d instanceof Error?d.message:String(d)}}}m(v,"syncFetch");function D(f,a={}){return v(f,{...a,headOnly:true})}m(D,"syncHead");function L(f){return f.map(a=>{try{return {regex:new RegExp(a.pattern),replacement:a.replacement}}catch{return null}}).filter(a=>a!==null)}m(L,"compileUrlRewrites");function B(f,a){let e=f;for(let s of a)e=e.replace(s.regex,s.replacement);return e}m(B,"applyUrlRewrites");var F={type:"array",items:{type:"object",properties:{pattern:{type:"string"},replacement:{type:"string"}}},description:"Regex rewrite rules applied to URLs before local file resolution. Rewrites that produce a local path are checked on disk."};function N(f,a,e){let s=[f];if(!u.extname(f)){for(let t of a)s.push(`${f}${t}`);s.push(u.join(f,e));}return s}m(N,"getLocalFileCandidates");function W(f){for(let a of f)try{if(g.existsSync(a)&&g.lstatSync(a).isFile())return a}catch{}return null}m(W,"resolveLocalFile");var fe={urlRewrites:[]},j=class j extends Rule{constructor(a){super({...fe,...a}),this.compiledUrlRewrites=L(this.options.urlRewrites);}static schema(){return {urlRewrites:{...F,description:"Regex rewrite rules mapping alternate URLs to local file paths for reciprocal checks."}}}documentation(){return {description:'Enforces best practices for alternate language links (rel="alternate" hreflang) in the <head> of HTML documents.',url:"https://developers.google.com/search/docs/specialty/international/localized-versions"}}setup(){this.on("dom:ready",a=>this.domReady(a));}loadLocalHtml(a){let e;if(a.startsWith("file://"))try{e=new URL(a).pathname;}catch{return null}else e=u.isAbsolute(a)?a:u.resolve(a);let s=N(e,[".html"],"index.html"),t=W(s);return t?g.readFileSync(t,"utf8"):null}fetchHtml(a){let e=B(a,this.compiledUrlRewrites);if(!/^https?:\/\//.test(e))return this.loadLocalHtml(e);let s=v(e,{timeoutSeconds:10,maxRedirs:5});return !s.success||!s.body?null:s.body}domReady(a){let{document:e}=a,s=e.querySelector("html");s&&this.checkAlternateLanguageLinks(s);}checkAlternateLanguageLinks(a){let e=a.querySelector("head");if(!e)return;let s=e.querySelectorAll('link[rel="alternate"][hreflang]');if(s.length===0)return;let t=e.querySelector('link[rel="canonical"]');if(!t)return;let r=t.getAttribute("href")?.value;if(!r||typeof r!="string")return;let i=r,o=a.getAttribute("lang")?.value,c=false;for(let n of s){let l=n.getAttribute("href")?.value,d=typeof l=="string"?l:void 0,h=n.getAttribute("hreflang")?.value;d===i&&(c=true,o&&h!==o&&this.report({node:n,message:`Self alternate link hreflang (${h}) does not match page lang (${o})`})),(!d||!/^https?:\/\//.test(d))&&this.report({node:n,message:`Alternate link href must be fully qualified: ${d??"(empty)"}`});}c||this.report({node:e,message:"No self-link found in alternate language links (must match canonical URL)"});for(let n of s){let l=n.getAttribute("href")?.value,d=typeof l=="string"?l:void 0;if(!d||d===i)continue;let h=this.fetchHtml(d);if(!h){this.report({node:n,message:`Alternate page ${d} is not accessible`});continue}let p=o??"",R=re.load(h),V=R('link[rel="alternate"][hreflang]'),y=false;V.each((se,M)=>{let oe=R(M).attr("href"),ce=R(M).attr("hreflang");if(oe===i&&ce===p)return y=true,false}),y||this.report({node:n,message:`Reciprocal alternate link not found on ${d} (must link back to ${i} with hreflang="${p}")`});}}};m(j,"AlternateLanguageLinksRule");var P=j;var q=class q extends Rule{documentation(){return {description:"Requires a <link rel='canonical'> in the <head> with a specific href format.",url:"https://github.com/fulldecent/html-validate-nice-checkers/blob/main/README.md#rules"}}setup(){this.on("dom:ready",a=>{this.domReady(a);});}domReady(a){let{document:e}=a,s=e.querySelector('head > link[rel="canonical"]');if(s===null){this.report({node:e.querySelector("head"),message:'<head> is missing <link rel="canonical" ...>'});return}let t=s.getAttribute("href")?.value;if(typeof t!="string"||!t){this.report({node:s,message:"<link rel='canonical'> is missing a non-empty href attribute"});return}let r;try{r=new URL(t).pathname;}catch{r=t;}/\.\w+$/.test(r)&&this.report({node:s,message:"Canonical link href should be extensionless (no .html, .php, etc.)"}),t.toLowerCase().endsWith("/index")&&this.report({node:s,message:'Canonical link href should be "/" instead of ending with "/index"'});}};m(q,"CanonicalLinkRule");var I=q;var ye={proxyUrl:"",skipRegexes:[],cacheExpiryFoundSeconds:720*60*60,cacheExpiryNotFoundSeconds:4320*60,timeoutSeconds:5,cacheDatabasePath:"cache/external-links.db",userAgent:"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.9999.999 Safari/537.36",manuallyReviewedPath:"",manuallyReviewedExpirySeconds:365*24*60*60,urlRewrites:[],alternativeExtensions:[".html"],indexFile:"index.html"};function U(f){try{let a=new URL(f);return a.hostname=a.hostname.toLowerCase(),a.toString()}catch{return f}}m(U,"normalizeUrl");var J=class J extends Rule{constructor(a){super({...ye,...a}),this.skipRegexesCompiled=this.compileRegexes(this.options.skipRegexes),this.manuallyReviewedUrls=this.loadManuallyReviewedUrls(),this.compiledUrlRewrites=L(this.options.urlRewrites??[]);}static schema(){return {proxyUrl:{type:"string",description:"URL of a proxy server to check external links."},skipRegexes:{type:"array",items:{type:"string"},description:"An array of regex patterns (as strings) for URLs to skip."},cacheExpiryFoundSeconds:{type:"number",description:"Number of seconds to cache successful (2xx) responses."},cacheExpiryNotFoundSeconds:{type:"number",description:"Number of seconds to cache failed (non-2xx) responses."},timeoutSeconds:{type:"number",description:"Maximum time in seconds to wait for a response."},cacheDatabasePath:{type:"string",description:"File path for the SQLite cache database."},userAgent:{type:"string",description:"User-Agent string to use for requests."},manuallyReviewedPath:{type:"string",description:"Path to CSV file containing manually reviewed URLs with approval timestamps."},manuallyReviewedExpirySeconds:{type:"number",description:"Number of seconds before a manually reviewed URL approval expires (default: 365 days)."},urlRewrites:{...F,description:"Regex rewrite rules mapping absolute https:// URLs to local paths. If a rewrite produces a local path, the file is checked on disk instead of over the network."},alternativeExtensions:{type:"array",items:{type:"string"},description:'Extensions to try when a rewritten local path has no extension (e.g. [".html"]).'},indexFile:{type:"string",description:'Filename to look for when a rewritten local path resolves to a directory (e.g. "index.html").'}}}documentation(){return {description:"Require all external links to be live.",url:"https://github.com/fulldecent/html-validate-nice-checkers/blob/main/README.md#rules"}}setup(){this.db=this.setupDatabase(),this.on("tag:ready",a=>this.tagReady(a));}setupDatabase(){let a=u.dirname(this.options.cacheDatabasePath);g.mkdirSync(a,{recursive:true});let e=new ge(this.options.cacheDatabasePath);return e.exec(`
4
4
  CREATE TABLE IF NOT EXISTS urls (url TEXT UNIQUE NOT NULL, status INTEGER, redirect_to TEXT, time INTEGER);
5
5
  CREATE INDEX IF NOT EXISTS time_idx ON urls (time);
6
6
  `),e.exec(`DELETE FROM urls WHERE status >= 200 AND status < 300 AND time < unixepoch() - ${this.options.cacheExpiryFoundSeconds}`),e.exec(`DELETE FROM urls WHERE (status < 200 OR status >= 300) AND time < unixepoch() - ${this.options.cacheExpiryNotFoundSeconds}`),e}compileRegexes(a){return a.map(e=>{try{return new RegExp(e)}catch{return console.error(`[html-validate-nice-checkers] Invalid regex pattern in 'skipRegexes' configuration: "${e}"`),null}}).filter(e=>e!==null)}loadManuallyReviewedUrls(){let a=new Map;if(!this.options.manuallyReviewedPath)return a;try{if(!this.options.manuallyReviewedPath||!g.existsSync(this.options.manuallyReviewedPath))return a;let s=g.readFileSync(this.options.manuallyReviewedPath,"utf-8").split(`