routerino 2.3.4 → 2.4.0

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.
@@ -1 +1 @@
1
- (function(p,l){typeof exports=="object"&&typeof module<"u"?l(exports,require("react/jsx-runtime"),require("react"),require("prop-types")):typeof define=="function"&&define.amd?define(["exports","react/jsx-runtime","react","prop-types"],l):(p=typeof globalThis<"u"?globalThis:p||self,l(p.routerino={},p["react/jsx-runtime"],p.React,p.PropTypes))})(this,function(p,l,$,e){"use strict";const V=[480,800,1200,1920],G="(max-width: 480px) 100vw, (max-width: 800px) 800px, (max-width: 1200px) 1200px, 1920px";function K(r,t=""){const i=r.toLowerCase(),n=t.toLowerCase();return!!(i.includes("hero")||i.includes("banner")||n.includes("hero")||n.includes("banner")||n.includes("h-screen")||n.includes("h-full"))}function M(r,t,i=null){const n=r.replace(/\.(jpe?g|png|webp)$/i,""),g=i?`.${i}`:r.match(/\.(jpe?g|png|webp)$/i)?.[0]||".jpg";return t.map(u=>`${n}-${u}w${g} ${u}w`).join(", ")}function F(r){const{src:t="",alt:i="",priority:n,widths:g=V,sizes:u=G,className:w="",style:q={},loading:E,decoding:v="async",fetchpriority:k,...U}=r||{};if(typeof window<"u"&&(window.location.hostname==="localhost"||window.location.hostname==="127.0.0.1"))return l.jsx("img",{src:t,alt:i,loading:E||"lazy",decoding:v,fetchPriority:k,className:w,style:q,...U});const[L,A]=$.useState(null),x=t&&typeof window<"u";$.useEffect(()=>{if(!x)return;const S=new F;S.onload=()=>{A({width:S.naturalWidth,height:S.naturalHeight})},S.src=t},[t,x]);const R=L?g.filter(S=>L.width>=S):g,[c,D]=$.useState(R);$.useEffect(()=>{(async()=>{if(typeof window>"u"){D(R);return}const j=t.replace(/\.(jpe?g|png|webp)$/i,""),o=t.match(/\.(jpe?g|png|webp)$/i)?.[0]||".jpg",s=[];for(const b of R){const h=`${j}-${b}w${o}`;try{(await fetch(h,{method:"HEAD"})).ok&&s.push(b)}catch{}}s.length===0&&R.length>0&&s.push(Math.min(...R)),D(s)})()},[t,R]);const W=n??K(t,w),C=E||(W?"eager":"lazy"),a=k||(W?"high":void 0),I=M(t,c,"webp"),O=M(t,c);return l.jsxs("picture",{"data-routerino-image":"true","data-original-src":t,children:[l.jsx("source",{srcSet:I,type:"image/webp",sizes:u}),l.jsx("img",{src:t,alt:i,srcSet:O,sizes:u,loading:C,decoding:v,fetchPriority:a,className:w,style:q,...U})]})}F.propTypes={src:e.string.isRequired,alt:e.string.isRequired,priority:e.bool,widths:e.arrayOf(e.number),sizes:e.string,className:e.string,style:e.object,loading:e.oneOf(["lazy","eager"]),decoding:e.oneOf(["sync","async","auto"]),fetchpriority:e.oneOf(["high","low","auto"])};const _=$.createContext(null);function Z(){const r=$.useContext(_);if(!r)throw new Error("useRouterino must be used within a Routerino router. Make sure your component is rendered inside a <Routerino> component.");return r}function d({tag:r="meta",soft:t=!1,...i}){const n=Object.keys(i);if(n.length<1)return console.error(`[Routerino] updateHeadTag() received no attributes to set for ${r} tag`);let g=null;for(let u=0;u<n.length&&(n[u]!=="content"&&(g=document.querySelector(`${r}[${n[u]}='${i[n[u]]}']`)),!g);u++);g&&t||(g||(g=document.createElement(r)),n.forEach(u=>g.setAttribute(u,i[u])),document.querySelector("head").appendChild(g))}function J({routePattern:r,currentRoute:t}){let i={},n=r.split("/"),g=t.split("/");return n.forEach((u,w)=>{u.startsWith(":")&&(i[u.slice(1)]=g[w])}),i}class H extends $.Component{constructor(t){super(t),this.state={hasError:!1}}static getDerivedStateFromError(){return{hasError:!0}}componentDidCatch(t,i){this.props.debug&&(console.group("%c[Routerino]%c Error Boundary Caught an Error","color: #ff6b6b; font-weight: bold","",t),console.error("[Routerino] Component Stack:",i.componentStack),this.props.routePath&&console.error("[Routerino] Failed Route:",this.props.routePath),console.error("[Routerino] Error occurred at:",new Date().toISOString()),console.groupEnd()),document.title=this.props.errorTitleString,this.props.usePrerenderTags&&d({name:"prerender-status-code",content:"500"})}render(){return this.state.hasError?this.props.fallback:this.props.children}}H.propTypes={children:e.node,fallback:e.node,errorTitleString:e.string.isRequired,usePrerenderTags:e.bool,routePath:e.string,debug:e.bool};function B({routes:r=[{path:"/",element:l.jsx("p",{children:"This is the default route. Pass an array of routes to the Routerino component in order to configure your own pages. Each route is a dictionary with at least `path` and `element` defined."}),title:"Routerino default route example",description:"The default route example description.",tags:[{property:"og:locale",content:"en_US"}]}],notFoundTemplate:t=l.jsxs(l.Fragment,{children:[l.jsx("p",{children:"No page found for this URL. [404]"}),l.jsx("p",{children:l.jsx("a",{href:"/",children:"Home"})})]}),notFoundTitle:i="Page not found [404]",errorTemplate:n=l.jsxs(l.Fragment,{children:[l.jsx("p",{children:"Page failed to load. [500]"}),l.jsx("p",{children:l.jsx("a",{href:"/",children:"Home"})})]}),errorTitle:g="Page error [500]",useTrailingSlash:u=!0,usePrerenderTags:w=!1,baseUrl:q=null,title:E="",separator:v=" | ",imageUrl:k=null,touchIconUrl:U=null,debug:f=!1}){const L=`${g}${v}${E}`,A=`${i}${v}${E}`;try{if(f){const o=r.map(b=>b.path),s=o.filter((b,h)=>o.indexOf(b)!==h);s.length>0&&(console.warn("%c[Routerino]%c Duplicate route paths detected:","color: #f59e0b; font-weight: bold","",[...new Set(s)]),console.warn("%c[Routerino]%c The first matching route will be used","color: #f59e0b; font-weight: bold",""))}const[x,R]=$.useState(window?.location?.href??"/");$.useEffect(()=>{if(typeof window>"u"||typeof document>"u")return;const o=b=>{f&&console.debug("%c[Routerino]%c click occurred","color: #6b7280; font-weight: bold","");let h=b.target;for(;h.tagName!=="A"&&h.parentElement;)h=h.parentElement;if(h.tagName!=="A"){f&&console.debug("%c[Routerino]%c no anchor tag found during click","color: #6b7280; font-weight: bold","");return}const m=h.getAttribute("href")||h.href;if(!m){f&&console.debug("%c[Routerino]%c anchor tag has no href","color: #6b7280; font-weight: bold","");return}if(!/^(https?:\/\/|\/|\.\/|\.\.\/|[^:]+$)/i.test(m)){f&&console.debug("%c[Routerino]%c skipping non-http URL:","color: #6b7280; font-weight: bold","",m);return}f&&console.debug("%c[Routerino]%c click target href:","color: #6b7280; font-weight: bold","",m);let y;try{y=new URL(m,window.location.href)}catch(z){f&&console.debug("%c[Routerino]%c Invalid URL:","color: #6b7280; font-weight: bold","",m,z);return}f&&console.debug("%c[Routerino]%c targetUrl:","color: #6b7280; font-weight: bold","",y,"current:",window.location),y&&window.location.origin===y.origin?(f&&console.debug("%c[Routerino]%c target link is same origin, will use push-state transitioning","color: #6b7280; font-weight: bold",""),b.preventDefault(),h.href!==window.location.href&&(R(h.href),window.history.pushState({},"",h.href)),window.scrollTo({top:0,behavior:"auto"})):f&&console.debug("%c[Routerino]%c target link does not share an origin, standard browser link handling applies","color: #6b7280; font-weight: bold","")};document.addEventListener("click",o);const s=()=>{f&&console.debug("%c[Routerino]%c route change ->","color: #6b7280; font-weight: bold","",window.location.pathname),R(window.location.href)};return window.addEventListener("popstate",s),()=>{document.removeEventListener("click",o),window.removeEventListener("popstate",s)}},[x]);let c=window?.location?.pathname??"/";(c==="/index.html"||c==="")&&(c="/");const D=r.find(o=>o.path===c),W=r.find(o=>`${o.path}/`===c||o.path===`${c}/`),C=r.find(o=>{const s=o.path.endsWith("/")?o.path.slice(0,-1):o.path,b=c.endsWith("/")?c.slice(0,-1):c,h=s.split("/").filter(Boolean),m=b.split("/").filter(Boolean);return h.length!==m.length?!1:h.every((y,z)=>y.startsWith(":")?!0:y===m[z])}),a=D??W??C;if(f&&console.debug("%c[Routerino]%c Route matching:","color: #6b7280; font-weight: bold","",{match:a,exactMatch:D,addSlashMatch:W,paramsMatch:C}),!a)return f&&(console.group("%c[Routerino]%c 404 - No matching route","color: #f59e0b; font-weight: bold",""),console.warn("%c[Routerino]%c Requested path:","color: #f59e0b; font-weight: bold","",c),console.warn("%c[Routerino]%c Available routes:","color: #f59e0b; font-weight: bold","",r.map(o=>o.path)),console.groupEnd()),document.title=A,w&&d({name:"prerender-status-code",content:"404"}),t;if(w){const o=document.querySelector('meta[name="prerender-status-code"]');o&&o.remove();const s=document.querySelector('meta[name="prerender-header"]');s&&s.remove()}const I=u&&!c.endsWith("/")&&c!=="/",O=!u&&c.endsWith("/")&&c!=="/",S=I?`${c}/`:O?c.slice(0,-1):c,j=`${q??window?.location?.origin??""}${S}`;if(a.title){const o=`${a.title}${v}${E}`;document.title=o,d({tag:"link",rel:"canonical",href:j}),a.tags?.find(({property:s})=>s==="og:title")||d({property:"og:title",content:o}),a.tags?.find(({property:s})=>s==="og:url")||d({property:"og:url",content:j})}if(a.description&&(d({name:"description",content:a.description}),a.tags?.find(({property:o})=>o==="og:description")||d({property:"og:description",content:a.description})),(k||a.imageUrl)&&d({property:"og:image",content:a.imageUrl??k}),a.tags?.find(({property:o})=>o==="twitter:card")||d({name:"twitter:card",content:"summary_large_image"}),U&&d({tag:"link",rel:"apple-touch-icon",href:U}),w&&(I||O)&&(d({name:"prerender-status-code",content:"301"}),d({name:"prerender-header",content:`Location: ${j}`})),a.tags&&a.tags.length?(a.tags.find(({property:o})=>o==="og:type")||d({property:"og:type",content:"website"}),a.tags.forEach(o=>d(o))):d({property:"og:type",content:"website"}),a.element){const o=J({routePattern:a.path,currentRoute:c}),s={currentRoute:c,params:o,routePattern:a.path,updateHeadTag:d};return l.jsx(_.Provider,{value:s,children:l.jsx(H,{fallback:n,errorTitleString:L,usePrerenderTags:w,routePath:c,debug:f,children:a.element})})}return f&&console.error("%c[Routerino]%c No route found for","color: #ff6b6b; font-weight: bold","",c),document.title=A,w&&d({name:"prerender-status-code",content:"404"}),t}catch(x){return f&&(console.group("%c[Routerino]%c Fatal Error","color: #ff6b6b; font-weight: bold",""),console.error("%c[Routerino]%c An error occurred in the router itself (not in a route component)","color: #ff6b6b; font-weight: bold",""),console.error("%c[Routerino]%c Error:","color: #ff6b6b; font-weight: bold","",x),console.error("%c[Routerino]%c This typically means an issue with route configuration or router setup","color: #ff6b6b; font-weight: bold",""),console.groupEnd()),w&&d({name:"prerender-status-code",content:"500"}),document.title=L,n}}const N=e.exact({path:(r,t,i)=>{const n=r[t];return n==null?new Error(`The prop \`${t}\` is marked as required in \`${i}\`, but its value is \`${n}\`.`):typeof n!="string"?new Error(`Invalid prop \`${t}\` of type \`${typeof n}\` supplied to \`${i}\`, expected \`string\`.`):n.startsWith("/")?null:new Error(`Invalid prop \`${t}\` value \`${n}\` supplied to \`${i}\`. Route paths must start with a forward slash (/).`)},element:e.element.isRequired,title:e.string,description:e.string,tags:e.arrayOf(e.object),imageUrl:e.string});B.propTypes={routes:e.arrayOf(N),title:e.string,separator:e.string,notFoundTemplate:e.element,notFoundTitle:e.string,errorTemplate:e.element,errorTitle:e.string,useTrailingSlash:e.bool,usePrerenderTags:e.bool,baseUrl:(r,t,i)=>{const n=r[t];if(n!=null){if(typeof n!="string")return new Error(`Invalid prop \`${t}\` of type \`${typeof n}\` supplied to \`${i}\`, expected \`string\`.`);if(n.endsWith("/"))return new Error(`Invalid prop \`${t}\` supplied to \`${i}\`. The baseUrl should not end with a slash. Got: "${n}"`)}return null},imageUrl:e.string,touchIconUrl:e.string,debug:e.bool},p.ErrorBoundary=H,p.Image=F,p.Routerino=B,p.default=B,p.updateHeadTag=d,p.useRouterino=Z,Object.defineProperties(p,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}})});
1
+ (function(w,c){typeof exports=="object"&&typeof module<"u"?c(exports,require("react/jsx-runtime"),require("react"),require("prop-types")):typeof define=="function"&&define.amd?define(["exports","react/jsx-runtime","react","prop-types"],c):(w=typeof globalThis<"u"?globalThis:w||self,c(w.routerino={},w["react/jsx-runtime"],w.React,w.PropTypes))})(this,(function(w,c,b,o){"use strict";const Q=[480,800,1200,1920],X="(max-width: 480px) 100vw, (max-width: 800px) 800px, (max-width: 1200px) 1200px, 1920px",A=new Map;function Y(i,e=""){const r=i.toLowerCase(),n=e.toLowerCase();return!!(r.includes("hero")||r.includes("banner")||n.includes("hero")||n.includes("banner")||n.includes("h-screen")||n.includes("h-full"))}function D(i,e,r=null){const n=i.replace(/\.(jpe?g|png|webp)$/i,""),f=r?`.${r}`:i.match(/\.(jpe?g|png|webp)$/i)?.[0]||".jpg";return e.map(s=>`${n}-${s}w${f} ${s}w`).join(", ")}function Z(i){const{src:e="",alt:r="",priority:n,widths:f=Q,sizes:s=X,className:m="",style:V={},width:R,height:x,loading:B,decoding:C="async",fetchpriority:p,...L}=i||{},q=typeof window>"u",S=!q&&typeof document<"u"&&typeof HTMLElement<"u"&&document.createElement("div")instanceof HTMLElement,y=S&&(window.location.hostname==="localhost"||window.location.hostname==="127.0.0.1"),a=n??Y(e,m),W=B||(a?"eager":"lazy"),I=p||(a?"high":void 0),[E,l]=b.useState(null);b.useEffect(()=>{if(!S||y||!e)return;const g=new window.Image;g.onload=()=>{l({width:g.naturalWidth,height:g.naturalHeight})},g.src=e},[e,S,y]);const k=b.useMemo(()=>E?f.filter(g=>E.width>=g):f,[E,f]),[O,G]=b.useState(null);b.useEffect(()=>{if(!S||y)return;let g=!1;return(async()=>{const U=e.replace(/\.(jpe?g|png|webp)$/i,""),F=e.match(/\.(jpe?g|png|webp)$/i)?.[0]||".jpg",M=[];for(const K of k){const H=`${U}-${K}w${F}`;if(A.has(H)){A.get(H)&&M.push(K);continue}try{const N=await fetch(H,{method:"HEAD"});A.set(H,N.ok),N.ok&&M.push(K)}catch{A.set(H,!1)}}g||G(M.length>0?M:k)})(),()=>{g=!0}},[e,k,S,y]);const j=O??k,t={};R!=null&&(t.width=R),x!=null&&(t.height=x),E&&R==null&&x==null&&(t.width=E.width,t.height=E.height);const u={maxWidth:"100%",height:"auto",...V};if(y)return c.jsx("img",{src:e,alt:r,loading:W,decoding:C,fetchPriority:I,className:m,style:u,...t,...L});if(q)return c.jsxs("picture",{"data-routerino-image":"true","data-original-src":e,children:[c.jsx("source",{srcSet:D(e,f,"webp"),type:"image/webp",sizes:s}),c.jsx("img",{src:e,alt:r,srcSet:D(e,f),sizes:s,loading:W,decoding:"async",fetchPriority:I,className:m,style:u,...t,...L})]});const $=D(e,j,"webp"),h=D(e,j);return c.jsxs("picture",{"data-routerino-image":"true","data-original-src":e,children:[c.jsx("source",{srcSet:$,type:"image/webp",sizes:s}),c.jsx("img",{src:e,alt:r,srcSet:h,sizes:s,loading:W,decoding:C,fetchPriority:I,className:m,style:u,...t,...L})]})}Z.propTypes={src:o.string.isRequired,alt:o.string.isRequired,priority:o.bool,widths:o.arrayOf(o.number),sizes:o.string,className:o.string,style:o.object,width:o.number,height:o.number,loading:o.oneOf(["lazy","eager"]),decoding:o.oneOf(["sync","async","auto"]),fetchpriority:o.oneOf(["high","low","auto"])};const J=b.createContext(null);function T(){const i=b.useContext(J);if(!i)throw new Error("useRouterino must be used within a Routerino router. Make sure your component is rendered inside a <Routerino> component.");return i}function d({tag:i="meta",soft:e=!1,...r}){const n=Object.keys(r);if(n.length<1)return console.error(`[Routerino] updateHeadTag() received no attributes to set for ${i} tag`);let f=null;for(let s=0;s<n.length&&(n[s]!=="content"&&(f=document.querySelector(`${i}[${n[s]}='${r[n[s]]}']`)),!f);s++);f&&e||(f||(f=document.createElement(i)),n.forEach(s=>f.setAttribute(s,r[s])),document.querySelector("head").appendChild(f))}function P({routePattern:i,currentRoute:e}){let r={},n=i.split("/"),f=e.split("/");return n.forEach((s,m)=>{s.startsWith(":")&&(r[s.slice(1)]=f[m])}),r}class _ extends b.Component{constructor(e){super(e),this.state={hasError:!1}}static getDerivedStateFromError(){return{hasError:!0}}componentDidCatch(e,r){this.props.debug&&(console.group("%c[Routerino]%c Error Boundary Caught an Error","color: #ff6b6b; font-weight: bold","",e),console.error("[Routerino] Component Stack:",r.componentStack),this.props.routePath&&console.error("[Routerino] Failed Route:",this.props.routePath),console.error("[Routerino] Error occurred at:",new Date().toISOString()),console.groupEnd()),document.title=this.props.errorTitleString,this.props.usePrerenderTags&&d({name:"prerender-status-code",content:"500"})}render(){return this.state.hasError?this.props.fallback:this.props.children}}_.propTypes={children:o.node,fallback:o.node,errorTitleString:o.string.isRequired,usePrerenderTags:o.bool,routePath:o.string,debug:o.bool};function z({routes:i=[{path:"/",element:c.jsx("p",{children:"This is the default route. Pass an array of routes to the Routerino component in order to configure your own pages. Each route is a dictionary with at least `path` and `element` defined."}),title:"Routerino default route example",description:"The default route example description.",tags:[{property:"og:locale",content:"en_US"}]}],notFoundTemplate:e=c.jsxs(c.Fragment,{children:[c.jsx("p",{children:"No page found for this URL. [404]"}),c.jsx("p",{children:c.jsx("a",{href:"/",children:"Home"})})]}),notFoundTitle:r="Page not found [404]",errorTemplate:n=c.jsxs(c.Fragment,{children:[c.jsx("p",{children:"Page failed to load. [500]"}),c.jsx("p",{children:c.jsx("a",{href:"/",children:"Home"})})]}),errorTitle:f="Page error [500]",useTrailingSlash:s=!0,usePrerenderTags:m=!1,baseUrl:V=null,title:R="",separator:x=" | ",imageUrl:B=null,touchIconUrl:C=null,debug:p=!1}){const L=`${f}${x}${R}`,q=`${r}${x}${R}`;try{if(p){const t=i.map($=>$.path),u=t.filter(($,h)=>t.indexOf($)!==h);u.length>0&&(console.warn("%c[Routerino]%c Duplicate route paths detected:","color: #f59e0b; font-weight: bold","",[...new Set(u)]),console.warn("%c[Routerino]%c The first matching route will be used","color: #f59e0b; font-weight: bold",""))}const[S,y]=b.useState(window?.location?.href??"/");b.useEffect(()=>{if(typeof window>"u"||typeof document>"u")return;const t=$=>{p&&console.debug("%c[Routerino]%c click occurred","color: #6b7280; font-weight: bold","");let h=$.target;for(;h.tagName!=="A"&&h.parentElement;)h=h.parentElement;if(h.tagName!=="A"){p&&console.debug("%c[Routerino]%c no anchor tag found during click","color: #6b7280; font-weight: bold","");return}const g=h.getAttribute("href")||h.href;if(!g){p&&console.debug("%c[Routerino]%c anchor tag has no href","color: #6b7280; font-weight: bold","");return}if(!/^(https?:\/\/|\/|\.\/|\.\.\/|[^:]+$)/i.test(g)){p&&console.debug("%c[Routerino]%c skipping non-http URL:","color: #6b7280; font-weight: bold","",g);return}p&&console.debug("%c[Routerino]%c click target href:","color: #6b7280; font-weight: bold","",g);let v;try{v=new URL(g,window.location.href)}catch(U){p&&console.debug("%c[Routerino]%c Invalid URL:","color: #6b7280; font-weight: bold","",g,U);return}if(p&&console.debug("%c[Routerino]%c targetUrl:","color: #6b7280; font-weight: bold","",v,"current:",window.location),v&&window.location.origin===v.origin)if(p&&console.debug("%c[Routerino]%c target link is same origin, will use push-state transitioning","color: #6b7280; font-weight: bold",""),$.preventDefault(),h.href!==window.location.href&&(y(h.href),window.history.pushState({},"",h.href)),h.hash){const U=decodeURIComponent(h.hash.slice(1));setTimeout(()=>{const F=document.getElementById(U);F?F.scrollIntoView({behavior:"auto"}):window.scrollTo({top:0,behavior:"auto"})},0)}else window.scrollTo({top:0,behavior:"auto"});else p&&console.debug("%c[Routerino]%c target link does not share an origin, standard browser link handling applies","color: #6b7280; font-weight: bold","")};document.addEventListener("click",t);const u=()=>{p&&console.debug("%c[Routerino]%c route change ->","color: #6b7280; font-weight: bold","",window.location.pathname),y(window.location.href)};return window.addEventListener("popstate",u),()=>{document.removeEventListener("click",t),window.removeEventListener("popstate",u)}},[S]);let a=window?.location?.pathname??"/";(a==="/index.html"||a==="")&&(a="/");const W=i.find(t=>t.path===a),I=i.find(t=>`${t.path}/`===a||t.path===`${a}/`),E=i.find(t=>{const u=t.path.endsWith("/")?t.path.slice(0,-1):t.path,$=a.endsWith("/")?a.slice(0,-1):a,h=u.split("/").filter(Boolean),g=$.split("/").filter(Boolean);return h.length!==g.length?!1:h.every((v,U)=>v.startsWith(":")?!0:v===g[U])}),l=W??I??E;if(p&&console.debug("%c[Routerino]%c Route matching:","color: #6b7280; font-weight: bold","",{match:l,exactMatch:W,addSlashMatch:I,paramsMatch:E}),!l)return p&&(console.group("%c[Routerino]%c 404 - No matching route","color: #f59e0b; font-weight: bold",""),console.warn("%c[Routerino]%c Requested path:","color: #f59e0b; font-weight: bold","",a),console.warn("%c[Routerino]%c Available routes:","color: #f59e0b; font-weight: bold","",i.map(t=>t.path)),console.groupEnd()),document.title=q,m&&d({name:"prerender-status-code",content:"404"}),e;if(m){const t=document.querySelector('meta[name="prerender-status-code"]');t&&t.remove();const u=document.querySelector('meta[name="prerender-header"]');u&&u.remove()}const k=s&&!a.endsWith("/")&&a!=="/",O=!s&&a.endsWith("/")&&a!=="/",G=k?`${a}/`:O?a.slice(0,-1):a,j=`${V??window?.location?.origin??""}${G}`;if(l.title){const t=`${l.title}${x}${R}`;document.title=t,d({tag:"link",rel:"canonical",href:j}),l.tags?.find(({property:u})=>u==="og:title")||d({property:"og:title",content:t}),l.tags?.find(({property:u})=>u==="og:url")||d({property:"og:url",content:j})}if(l.description&&(d({name:"description",content:l.description}),l.tags?.find(({property:t})=>t==="og:description")||d({property:"og:description",content:l.description})),(B||l.imageUrl)&&d({property:"og:image",content:l.imageUrl??B}),l.tags?.find(({property:t})=>t==="twitter:card")||d({name:"twitter:card",content:"summary_large_image"}),C&&d({tag:"link",rel:"apple-touch-icon",href:C}),m&&(k||O)&&(d({name:"prerender-status-code",content:"301"}),d({name:"prerender-header",content:`Location: ${j}`})),l.tags&&l.tags.length?(l.tags.find(({property:t})=>t==="og:type")||d({property:"og:type",content:"website"}),l.tags.forEach(t=>d(t))):d({property:"og:type",content:"website"}),l.element){const t=P({routePattern:l.path,currentRoute:a}),u={currentRoute:a,params:t,routePattern:l.path,updateHeadTag:d};return c.jsx(J.Provider,{value:u,children:c.jsx(_,{fallback:n,errorTitleString:L,usePrerenderTags:m,routePath:a,debug:p,children:l.element})})}return p&&console.error("%c[Routerino]%c No route found for","color: #ff6b6b; font-weight: bold","",a),document.title=q,m&&d({name:"prerender-status-code",content:"404"}),e}catch(S){return p&&(console.group("%c[Routerino]%c Fatal Error","color: #ff6b6b; font-weight: bold",""),console.error("%c[Routerino]%c An error occurred in the router itself (not in a route component)","color: #ff6b6b; font-weight: bold",""),console.error("%c[Routerino]%c Error:","color: #ff6b6b; font-weight: bold","",S),console.error("%c[Routerino]%c This typically means an issue with route configuration or router setup","color: #ff6b6b; font-weight: bold",""),console.groupEnd()),m&&d({name:"prerender-status-code",content:"500"}),document.title=L,n}}const ee=o.exact({path:(i,e,r)=>{const n=i[e];return n==null?new Error(`The prop \`${e}\` is marked as required in \`${r}\`, but its value is \`${n}\`.`):typeof n!="string"?new Error(`Invalid prop \`${e}\` of type \`${typeof n}\` supplied to \`${r}\`, expected \`string\`.`):n.startsWith("/")?null:new Error(`Invalid prop \`${e}\` value \`${n}\` supplied to \`${r}\`. Route paths must start with a forward slash (/).`)},element:o.element.isRequired,title:o.string,description:o.string,tags:o.arrayOf(o.object),imageUrl:o.string});z.propTypes={routes:o.arrayOf(ee),title:o.string,separator:o.string,notFoundTemplate:o.element,notFoundTitle:o.string,errorTemplate:o.element,errorTitle:o.string,useTrailingSlash:o.bool,usePrerenderTags:o.bool,baseUrl:(i,e,r)=>{const n=i[e];if(n!=null){if(typeof n!="string")return new Error(`Invalid prop \`${e}\` of type \`${typeof n}\` supplied to \`${r}\`, expected \`string\`.`);if(n.endsWith("/"))return new Error(`Invalid prop \`${e}\` supplied to \`${r}\`. The baseUrl should not end with a slash. Got: "${n}"`)}return null},imageUrl:o.string,touchIconUrl:o.string,debug:o.bool},w.ErrorBoundary=_,w.Image=Z,w.Routerino=z,w.default=z,w.updateHeadTag=d,w.useRouterino=T,Object.defineProperties(w,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}})}));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "routerino",
3
- "version": "2.3.4",
3
+ "version": "2.4.0",
4
4
  "description": "A lightweight, SEO-optimized React router for modern web applications",
5
5
  "repository": {
6
6
  "type": "git",
@@ -66,6 +66,7 @@
66
66
  "@vitejs/plugin-react": "^5.0.4",
67
67
  "eslint": "^9.38.0",
68
68
  "eslint-plugin-react": "^7.37.5",
69
+ "eslint-plugin-react-refresh": "^0.4.26",
69
70
  "express": "^5.1.0",
70
71
  "globals": "^16.4.0",
71
72
  "husky": "^9.1.7",
@@ -24,7 +24,7 @@ const shouldProcessImage = (src) =>
24
24
  // Default configuration for Image component processing
25
25
  const DEFAULT_IMAGE_CONFIG = {
26
26
  widths: [480, 800, 1200, 1920],
27
- formats: ["webp"], // WebP is sufficient for Lighthouse 100/100
27
+ formats: ["webp"],
28
28
  placeholderSize: 20,
29
29
  blur: 4,
30
30
  maxSize: 10485760, // 10MB
@@ -33,7 +33,7 @@ const DEFAULT_IMAGE_CONFIG = {
33
33
  };
34
34
 
35
35
  // Get image dimensions using ffprobe
36
- function getImageDimensionsWithFfprobe(inputPath) {
36
+ function getImageDimensionsWithFfprobe(inputPath, config) {
37
37
  return new Promise((resolve, reject) => {
38
38
  const args = [
39
39
  "-v",
@@ -47,7 +47,7 @@ function getImageDimensionsWithFfprobe(inputPath) {
47
47
  inputPath,
48
48
  ];
49
49
 
50
- const ffprobe = spawn("ffprobe", args);
50
+ const ffprobe = spawn(config.binaryPaths.ffprobe, args);
51
51
 
52
52
  let output = "";
53
53
  let errorOutput = "";
@@ -103,33 +103,68 @@ async function generateResponsiveImages(
103
103
  inputFileName.match(/\.(jpe?g|png)$/i)?.[0] || ".jpg";
104
104
 
105
105
  // Get dimensions using ffprobe
106
- const dimensions = await getImageDimensionsWithFfprobe(inputPath).catch(
107
- () => null
108
- );
106
+ let dimensions = null;
107
+ try {
108
+ dimensions = await getImageDimensionsWithFfprobe(inputPath, config);
109
+ } catch (error) {
110
+ if (config.verbose) {
111
+ console.warn(
112
+ `[Routerino Image] Failed to get dimensions for ${inputPath}: ${error.message}`
113
+ );
114
+ }
115
+ }
109
116
 
110
117
  // Generate placeholder (LQIP)
111
118
  const placeholder = await generatePlaceholder(
112
119
  inputPath,
113
- config.placeholderSize
120
+ config.placeholderSize,
121
+ config
114
122
  );
115
123
 
124
+ // Validate dimensions and log issues
125
+ if (dimensions) {
126
+ if (dimensions.width <= 0 || dimensions.height <= 0) {
127
+ if (config.verbose) {
128
+ console.warn(
129
+ `[Routerino Image] Invalid dimensions for ${inputPath}: ${dimensions.width}x${dimensions.height}`
130
+ );
131
+ }
132
+ dimensions = null;
133
+ } else if (config.verbose) {
134
+ console.log(
135
+ `[Routerino Image] Detected dimensions for ${inputPath}: ${dimensions.width}x${dimensions.height}`
136
+ );
137
+ }
138
+ }
139
+
116
140
  const results = {
117
141
  placeholder,
118
- width: dimensions?.width || 0,
119
- height: dimensions?.height || 0,
142
+ width: dimensions?.width ?? null,
143
+ height: dimensions?.height ?? null,
120
144
  variants: {},
121
145
  };
122
146
 
123
147
  // Filter widths to only include those applicable to this image
124
- const applicableWidths = config.widths.filter(
125
- (width) => !dimensions || dimensions.width >= width
126
- );
148
+ // Prevent upscaling by only generating widths that don't exceed the original
149
+ const applicableWidths = dimensions
150
+ ? config.widths.filter((width) => width <= dimensions.width)
151
+ : config.widths; // Fallback: generate all widths if dimensions unknown
152
+
153
+ if (dimensions && applicableWidths.length === 0) {
154
+ if (config.verbose) {
155
+ console.warn(
156
+ `[Routerino Image] Original image ${inputPath} (${dimensions.width}px) is smaller than the smallest configured width (${config.widths[0]}px)`
157
+ );
158
+ }
159
+ // Add the original width as a variant to prevent complete failure
160
+ applicableWidths.push(dimensions.width);
161
+ }
127
162
 
128
163
  // Generate responsive variants for applicable widths only
129
164
  for (const width of applicableWidths) {
130
165
  // Generate WebP version in output directory
131
166
  const webpPath = path.join(outputDir, `${base}-${width}w.webp`);
132
- await generateImageVariant(inputPath, webpPath, width, "webp");
167
+ await generateImageVariant(inputPath, webpPath, width, "webp", config);
133
168
 
134
169
  // Generate original format version in output directory
135
170
  const originalPath = path.join(
@@ -140,7 +175,8 @@ async function generateResponsiveImages(
140
175
  inputPath,
141
176
  originalPath,
142
177
  width,
143
- originalExtension.slice(1)
178
+ originalExtension.slice(1),
179
+ config
144
180
  );
145
181
 
146
182
  results.variants[width] = {
@@ -162,7 +198,13 @@ async function generateResponsiveImages(
162
198
  /**
163
199
  * Generate a single image variant at specified width
164
200
  */
165
- async function generateImageVariant(inputPath, outputPath, width, format) {
201
+ async function generateImageVariant(
202
+ inputPath,
203
+ outputPath,
204
+ width,
205
+ format,
206
+ config
207
+ ) {
166
208
  return new Promise((resolve, reject) => {
167
209
  // Build ffmpeg command based on format
168
210
  const args = ["-i", inputPath, "-vf", `scale=${width}:-2`];
@@ -175,7 +217,7 @@ async function generateImageVariant(inputPath, outputPath, width, format) {
175
217
 
176
218
  args.push("-y", outputPath); // Overwrite existing files
177
219
 
178
- const ffmpeg = spawn("ffmpeg", args);
220
+ const ffmpeg = spawn(config.binaryPaths.ffmpeg, args);
179
221
  let errorOutput = "";
180
222
 
181
223
  ffmpeg.stderr.on("data", (data) => {
@@ -200,7 +242,7 @@ async function generateImageVariant(inputPath, outputPath, width, format) {
200
242
  /**
201
243
  * Generate LQIP placeholder using ffmpeg
202
244
  */
203
- async function generatePlaceholder(inputPath, targetHeight = 20) {
245
+ async function generatePlaceholder(inputPath, targetHeight = 20, config) {
204
246
  return new Promise((resolve, reject) => {
205
247
  const args = [
206
248
  "-i",
@@ -216,7 +258,7 @@ async function generatePlaceholder(inputPath, targetHeight = 20) {
216
258
  "pipe:1",
217
259
  ];
218
260
 
219
- const ffmpeg = spawn("ffmpeg", args);
261
+ const ffmpeg = spawn(config.binaryPaths.ffmpeg, args);
220
262
  let chunks = [];
221
263
  let errorOutput = "";
222
264
 
@@ -260,7 +302,7 @@ async function getImageCacheKey(imagePath, config = DEFAULT_IMAGE_CONFIG) {
260
302
  * Process <Image> components in HTML to add responsive images + LQIP
261
303
  * Only processes elements with data-routerino-image="true"
262
304
  */
263
- async function processRouterInoImages(html, outputDir) {
305
+ async function processRouterInoImages(html, outputDir, config) {
264
306
  const stats = {
265
307
  processed: 0,
266
308
  skipped: 0,
@@ -269,6 +311,28 @@ async function processRouterInoImages(html, outputDir) {
269
311
  errors: [],
270
312
  };
271
313
 
314
+ // Lazy binary setup: check if HTML contains Image components and setup binaries if needed
315
+ const hasImagesInHtml = html.includes('data-routerino-image="true"');
316
+ if (hasImagesInHtml && !config.binaryPaths) {
317
+ try {
318
+ const binaryInfo = await ensureBinariesAvailable();
319
+ config.binaryPaths = {
320
+ ffmpeg: binaryInfo.ffmpegPath,
321
+ ffprobe: binaryInfo.ffprobePath,
322
+ };
323
+ } catch {
324
+ // Warn once and return HTML unmodified — images will still work
325
+ // using their original src, just without responsive variants or LQIP
326
+ if (!config._binaryWarningShown) {
327
+ console.warn(
328
+ `[Routerino Image] ffmpeg/ffprobe not available, skipping image optimization. Install ffmpeg to enable: https://ffmpeg.org/download.html`
329
+ );
330
+ config._binaryWarningShown = true;
331
+ }
332
+ return { html, stats };
333
+ }
334
+ }
335
+
272
336
  // Ensure cache directory exists
273
337
  const cacheDir = path.resolve(DEFAULT_IMAGE_CONFIG.cacheDir);
274
338
  await fs.mkdir(cacheDir, { recursive: true }).catch(() => {});
@@ -304,6 +368,11 @@ async function processRouterInoImages(html, outputDir) {
304
368
  fileStats.size < DEFAULT_IMAGE_CONFIG.minSize ||
305
369
  fileStats.size > DEFAULT_IMAGE_CONFIG.maxSize
306
370
  ) {
371
+ if (config.verbose) {
372
+ console.warn(
373
+ `[Routerino Image] Skipping ${originalSrc}: size ${fileStats.size} bytes (min: ${DEFAULT_IMAGE_CONFIG.minSize}, max: ${DEFAULT_IMAGE_CONFIG.maxSize})`
374
+ );
375
+ }
307
376
  stats.skipped++;
308
377
  continue;
309
378
  }
@@ -326,9 +395,17 @@ async function processRouterInoImages(html, outputDir) {
326
395
  imageData = cached;
327
396
  } catch {
328
397
  // Generate responsive images + placeholder
329
- imageData = await generateResponsiveImages(imagePath, outputDir);
398
+ imageData = await generateResponsiveImages(imagePath, outputDir, {
399
+ ...DEFAULT_IMAGE_CONFIG,
400
+ ...config,
401
+ });
330
402
 
331
403
  if (!imageData) {
404
+ if (config.verbose) {
405
+ console.warn(
406
+ `[Routerino Image] Failed to process ${originalSrc}: no image data returned`
407
+ );
408
+ }
332
409
  stats.skipped++;
333
410
  continue;
334
411
  }
@@ -360,7 +437,13 @@ async function processRouterInoImages(html, outputDir) {
360
437
  }
361
438
 
362
439
  /**
363
- * Transform a picture element to include LQIP background and update srcsets
440
+ * Transform a picture element to include LQIP background and update srcsets.
441
+ *
442
+ * The LQIP placeholder is applied as a background-image on the <picture>
443
+ * element itself, with the blurred placeholder visible behind the <img>.
444
+ * No wrapper div or <style> block is injected — only inline styles on the
445
+ * existing <picture> and <img> elements — so the DOM structure matches what
446
+ * the React Image component renders on the client, avoiding hydration mismatches.
364
447
  */
365
448
  async function transformPictureWithLQIP(
366
449
  pictureHTML,
@@ -368,31 +451,33 @@ async function transformPictureWithLQIP(
368
451
  imageData,
369
452
  config
370
453
  ) {
371
- // Create unique class for LQIP styling
372
- const uniqueClass = `routerino-img-${Math.random().toString(36).substring(2, 11)}`;
373
-
374
- // LQIP background styles
375
- const lqipStyle = `
376
- .${uniqueClass} {
377
- position: relative;
378
- display: inline-block;
379
- ${imageData.width && imageData.height ? `aspect-ratio: ${imageData.width / imageData.height};` : ""}
380
- }
381
- .${uniqueClass}::before {
382
- content: '';
383
- position: absolute;
384
- top: 0; left: 0; right: 0; bottom: 0;
385
- background-image: url('${imageData.placeholder}');
386
- background-size: cover;
387
- background-position: center;
388
- filter: blur(${config.blur}px);
389
- z-index: -1;
454
+ let updatedPicture = pictureHTML;
455
+
456
+ // Build LQIP inline styles for the <picture> element
457
+ const pictureStyles = [
458
+ "display:block",
459
+ `background-image:url('${imageData.placeholder}')`,
460
+ "background-size:cover",
461
+ "background-position:center",
462
+ `filter:blur(${config.blur}px)`,
463
+ ];
464
+
465
+ // Add LQIP styles to the <picture> element
466
+ updatedPicture = updatedPicture.replace(
467
+ /(<picture)([^>]*>)/i,
468
+ (match, tag, rest) => {
469
+ if (rest.includes("style=")) {
470
+ // Append to existing style
471
+ return match.replace(
472
+ /style=["']([^"']*)/i,
473
+ `style="${pictureStyles.join(";")};$1`
474
+ );
475
+ }
476
+ return `${tag} style="${pictureStyles.join(";")}"${rest}`;
390
477
  }
391
- `;
478
+ );
392
479
 
393
480
  // Update srcsets to point to generated responsive images
394
- let updatedPicture = pictureHTML;
395
-
396
481
  // Update WebP source srcset using actual generated paths
397
482
  const webpSrcSet = Object.entries(imageData.variants)
398
483
  .map(([width, paths]) => `${paths.webp} ${width}w`)
@@ -422,21 +507,33 @@ async function transformPictureWithLQIP(
422
507
  );
423
508
  }
424
509
 
425
- // Add opacity: 0 to img tag
510
+ // Add width/height attributes and ensure protective styles on <img>
426
511
  updatedPicture = updatedPicture.replace(
427
512
  /<img([^>]*?)\/?>/i,
428
513
  (match, attributes) => {
429
- // Add opacity: 0 to existing style or create new style attribute
430
- if (attributes.includes("style=")) {
431
- return match.replace(/style=["']([^"']*)/i, 'style="opacity: 0; $1');
432
- } else {
433
- return `<img${attributes} style="opacity: 0"/>`;
514
+ let newMatch = match;
515
+
516
+ // Add width/height attributes if dimensions are available and not already present
517
+ if (imageData.width && imageData.height) {
518
+ if (!attributes.includes("width=")) {
519
+ newMatch = newMatch.replace(
520
+ "<img",
521
+ `<img width="${imageData.width}"`
522
+ );
523
+ }
524
+ if (!attributes.includes("height=")) {
525
+ newMatch = newMatch.replace(
526
+ "<img",
527
+ `<img height="${imageData.height}"`
528
+ );
529
+ }
434
530
  }
531
+
532
+ return newMatch;
435
533
  }
436
534
  );
437
535
 
438
- // Wrap the picture in a div with LQIP styling
439
- return `<style>${lqipStyle}</style><div class="${uniqueClass}">${updatedPicture}</div>`;
536
+ return updatedPicture;
440
537
  }
441
538
 
442
539
  export function routerinoForge(options = {}) {
@@ -692,6 +789,7 @@ export function render(url, baseUrl) {
692
789
  console.log("[Routerino Forge] Forging SSG bundle...");
693
790
  await build({
694
791
  root: viteConfig.root,
792
+ configFile: viteConfig.configFile,
695
793
  build: {
696
794
  ssr: ssgEntryPath,
697
795
  outDir: ssgOutDir,
@@ -699,6 +797,7 @@ export function render(url, baseUrl) {
699
797
  output: {
700
798
  format: "es",
701
799
  entryFileNames: "entry-server.mjs",
800
+ manualChunks: () => null,
702
801
  },
703
802
  },
704
803
  },
@@ -1281,4 +1380,30 @@ Sitemap: ${config.baseUrl}/sitemap.xml`;
1281
1380
  }
1282
1381
  }
1283
1382
 
1383
+ // Binary availability check for ffmpeg/ffprobe
1384
+ async function testBinary(binaryPath) {
1385
+ return new Promise((resolve) => {
1386
+ const test = spawn(binaryPath, ["-version"], { stdio: "ignore" });
1387
+ test.on("close", (code) => resolve(code === 0));
1388
+ test.on("error", () => resolve(false));
1389
+
1390
+ // Timeout after 5 seconds
1391
+ setTimeout(() => {
1392
+ test.kill();
1393
+ resolve(false);
1394
+ }, 5000);
1395
+ });
1396
+ }
1397
+
1398
+ async function ensureBinariesAvailable() {
1399
+ if ((await testBinary("ffmpeg")) && (await testBinary("ffprobe"))) {
1400
+ return {
1401
+ ffmpegPath: "ffmpeg",
1402
+ ffprobePath: "ffprobe",
1403
+ };
1404
+ }
1405
+
1406
+ throw new Error("ffmpeg/ffprobe not found in PATH");
1407
+ }
1408
+
1284
1409
  export default routerinoForge;