@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/README.md CHANGED
@@ -458,6 +458,36 @@ Note that these sources we reference have a conflict. One says that you may use
458
458
  | ------------- | -------------------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------- |
459
459
  | `urlRewrites` | `{ pattern: string, replacement: string }[]` | `[]` | Regex rewrite rules applied to each alternate URL before reciprocal validation. Useful for local fixtures. |
460
460
 
461
+ ### `nice-checkers/schema-org-json-ld`
462
+
463
+ Validates `<script type="application/ld+json">` structured data against the bundled Schema.org vocabulary. This rule catches typos and mistakes in class names, property names, and property values before search engines silently ignore your structured data.
464
+
465
+ ```diff
466
+ <script type="application/ld+json">
467
+ {
468
+ "@context": "https://schema.org",
469
+ - "@type": "MyTotallyFakeClass",
470
+ + "@type": "WebSite",
471
+ "name": "My Awesome Site",
472
+ "url": "https://example.com/"
473
+ }
474
+ </script>
475
+ ```
476
+
477
+ #### Configuration
478
+
479
+ ```json
480
+ {
481
+ "rules": {
482
+ "nice-checkers/schema-org-json-ld": "error"
483
+ }
484
+ }
485
+ ```
486
+
487
+ #### Configuration options
488
+
489
+ This rule has no configurable options.
490
+
461
491
  ### Example configuration for local build validation
462
492
 
463
493
  ```json
package/dist/index.cjs CHANGED
@@ -1,6 +1,6 @@
1
1
  'use strict';var htmlValidate=require('html-validate'),child_process=require('child_process'),shellQuote=require('shell-quote'),re=require('cheerio'),g=require('fs'),u=require('path'),ge=require('better-sqlite3');function _interopDefault(e){return e&&e.__esModule?e:{default:e}}function _interopNamespace(e){if(e&&e.__esModule)return e;var n=Object.create(null);if(e){Object.keys(e).forEach(function(k){if(k!=='default'){var d=Object.getOwnPropertyDescriptor(e,k);Object.defineProperty(n,k,d.get?d:{enumerable:true,get:function(){return e[k]}});}})}n.default=e;return Object.freeze(n)}var re__namespace=/*#__PURE__*/_interopNamespace(re);var g__default=/*#__PURE__*/_interopDefault(g);var u__default=/*#__PURE__*/_interopDefault(u);var ge__default=/*#__PURE__*/_interopDefault(ge);var ne=Object.defineProperty;var m=(f,a)=>ne(f,"name",{value:a,configurable:true});var G=class G extends htmlValidate.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=shellQuote.quote([f]),n=["curl","--silent","--max-time",String(e),"--max-redirs",String(r),"--user-agent",shellQuote.quote([s])];t&&n.push("--head"),i&&n.push("--fail");for(let[d,h]of Object.entries(o))n.push("--header",shellQuote.quote([`${d}: ${h}`]));n.push("--dump-header","-"),t&&n.push("--output","/dev/null"),n.push(c);let l=n.join(" ")+" || true";try{let d=child_process.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__default.default.extname(f)){for(let t of a)s.push(`${f}${t}`);s.push(u__default.default.join(f,e));}return s}m(N,"getLocalFileCandidates");function W(f){for(let a of f)try{if(g__default.default.existsSync(a)&&g__default.default.lstatSync(a).isFile())return a}catch{}return null}m(W,"resolveLocalFile");var fe={urlRewrites:[]},j=class j extends htmlValidate.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__default.default.isAbsolute(a)?a:u__default.default.resolve(a);let s=N(e,[".html"],"index.html"),t=W(s);return t?g__default.default.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__namespace.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 htmlValidate.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 htmlValidate.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__default.default.dirname(this.options.cacheDatabasePath);g__default.default.mkdirSync(a,{recursive:true});let e=new ge__default.default(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__default.default.extname(f)){for(let t of a)s.push(`${f}${t}`);s.push(u__default.default.join(f,e));}return s}m(N,"getLocalFileCandidates");function W(f){for(let a of f)try{if(g__default.default.existsSync(a)&&g__default.default.lstatSync(a).isFile())return a}catch{}return null}m(W,"resolveLocalFile");var fe={urlRewrites:[]},j=class j extends htmlValidate.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__default.default.isAbsolute(a)?a:u__default.default.resolve(a);let s=N(e,[".html"],"index.html"),t=W(s);return t?g__default.default.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__namespace.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 htmlValidate.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 htmlValidate.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__default.default.dirname(this.options.cacheDatabasePath);g__default.default.mkdirSync(a,{recursive:true});let e=new ge__default.default(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__default.default.existsSync(this.options.manuallyReviewedPath))return a;let s=g__default.default.readFileSync(this.options.manuallyReviewedPath,"utf-8").split(`