88 lines
3.3 KiB
TypeScript
88 lines
3.3 KiB
TypeScript
|
import * as Signal from "@preact/signals";
|
||
|
import * as React from "react";
|
||
|
|
||
|
///////// Metas
|
||
|
type MetaFields = {title?:string, description?:string};
|
||
|
type MetaRecord = MetaFields&{id:string}
|
||
|
const Stack = [] as MetaRecord[];
|
||
|
const StackPush =(m:MetaRecord)=> {Stack.push(m); Update();}
|
||
|
const StackPop =(id:string)=> {Stack.splice(Stack.findIndex( item => item.id === id ), 1); Update();}
|
||
|
const Update =()=> document.title = Stack[Stack.length-1]?.title || "---";
|
||
|
Update();
|
||
|
export const useMeta=(fields:MetaFields)=>
|
||
|
{
|
||
|
const id = React.useId();
|
||
|
React.useEffect(()=>{
|
||
|
StackPush({...fields, id});
|
||
|
return ()=>StackPop(id);
|
||
|
}, []);
|
||
|
}
|
||
|
export const Meta =(props:MetaFields)=> { useMeta(props); return null; }
|
||
|
|
||
|
//////// Router
|
||
|
//// Create Signals
|
||
|
export const pageURL = Signal.signal(new URL(globalThis.location.href || ""));
|
||
|
export const pagePath = Signal.signal([] as string[]);
|
||
|
Signal.effect(()=> pagePath.value = pageURL.value.pathname.split("/").filter(part=>part!=""));
|
||
|
|
||
|
//// Add handlers
|
||
|
globalThis.addEventListener("click", e=>
|
||
|
{
|
||
|
(e.composedPath() as HTMLAnchorElement[]).find((step)=>{
|
||
|
if(step.href)
|
||
|
{
|
||
|
const url = new URL(step.href);
|
||
|
if(url.origin == document.location.origin)
|
||
|
{
|
||
|
e.preventDefault();
|
||
|
history.pushState({}, "", url);
|
||
|
pageURL.value = url;
|
||
|
}
|
||
|
return true;
|
||
|
}
|
||
|
})
|
||
|
});
|
||
|
globalThis.addEventListener("popstate", _=> pageURL.value = new URL(globalThis.location.href) );
|
||
|
|
||
|
/// Rendering context
|
||
|
type RouteContextData = {
|
||
|
/** Current nested depth of the router */ nestedDepth:number,
|
||
|
/** Index into the page path to start matching routes */ pathIndex:number,
|
||
|
/** Collection of keys from matched routes ("page/:key/") */ keys: Record<string, string>
|
||
|
}
|
||
|
const context = React.createContext({nestedDepth:0, pathIndex:0, keys:{}} as RouteContextData);
|
||
|
export const useRoute =()=> React.useContext(context);
|
||
|
export const Route =(props:{path:string[], children:React.ReactNode|React.ReactNode[]}):React.JSX.Element|null=>
|
||
|
{
|
||
|
// Match the current page url, with a route.
|
||
|
const {nestedDepth, pathIndex} = React.useContext(context);
|
||
|
const pagePathRange = pagePath.value.slice(pathIndex);
|
||
|
const emptyMatch = !props.path.length && !pagePathRange.length;
|
||
|
const comparisonSize = Math.min(props.path.length, pagePath.value.length);
|
||
|
|
||
|
if(comparisonSize == 0 && emptyMatch === false) // one of the arrays is empty and one is not
|
||
|
{
|
||
|
return null;
|
||
|
}
|
||
|
|
||
|
const keys = {} as Record<string, string>; // not doing anything with this currently
|
||
|
for(let i=0; i<comparisonSize; i++)
|
||
|
{
|
||
|
const partPage = pagePathRange[i];
|
||
|
const partRoute = props.path[i];
|
||
|
if(partPage !== partRoute) // theres a mismatch
|
||
|
{
|
||
|
if(partRoute?.startsWith(":")) // mismatch because variable capture, consider it a match and capture
|
||
|
{
|
||
|
const key = partRoute?.substring(1);
|
||
|
key && (keys[key] = partPage);
|
||
|
}
|
||
|
else if(partRoute !== "*") // otherwise quit if its not a wildcard section
|
||
|
{
|
||
|
return null;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return <context.Provider value={{nestedDepth:nestedDepth+1, pathIndex:pathIndex+comparisonSize, keys}}>{props.children}</context.Provider>
|
||
|
}
|