import { html, LitElement } from "lit"; import { customElement, property } from "lit/decorators.js"; import { templateContent } from "lit/directives/template-content.js"; import LocationHandler from "@supersoniks/concorde/core/utils/LocationHandler"; import { repeat } from "lit/directives/repeat.js"; import UrlPattern from "@supersoniks/concorde/core/utils/url-pattern"; import TemplatesContainer from "@supersoniks/concorde/core/mixins/TemplatesContainer"; import { DirectiveResult } from "lit/directive.js"; import { Routes } from "@supersoniks/concorde/core/utils/route"; const tagName = "sonic-router"; /** * ### Le router observe les modification document.location et met à jour sa vu de la manière suivante : * * Il boucle sur ses template enfants et test si la regexp contenue dans l'attribut *data-route* match document.location * * Si oui le contenu du template est affiché * * Si l'attribut dataProviderExpression est fourni le contenu est entouré d'une div : * * L'attribut "dataProvider" de cette div est le resultat de l'appel à la fonction replace sur la propriété location avec comme paramettres la regexpe et dataproviderExpression. * * On peut également utiliser les des expressions du type url-pattern pour les paramètres de la route voir les exemples * * les subscribers/fetch... du template ser réfèrerons à ce dataProvider * * **Exemples ** * Avec location = /youpla/utilisateur/2 : * * RegExp : data-route = /utilisateur/(\d+) et dataproviderExpression = /user/$1 l'attribut dataProvider vaudra "/user/2" * * url-pattern : /*utilisateur/:id et dataproviderExpression = /user/:id l'attribut dataProvider vaudra "/user/2" */ type ExtractParamNames = T extends `${string}:${infer Param}/${infer Rest}` ? Param | ExtractParamNames : T extends `${string}:${infer Param}` ? Param : never; type ParamsObject = { [K in ExtractParamNames]: string; }; type RouteFunction = ( params: ParamsObject ) => DirectiveResult; type RoutesToRenderer = { fallback?: () => DirectiveResult; [path: string | keyof (typeof Routes)["routes"]]: | RouteFunction | undefined; }; @customElement(tagName) export class SonicRouter extends TemplatesContainer(LitElement) { templateValueAttribute = "data-route"; @property({ type: String }) fallBackRoute?: string; @property({ type: Object }) routes?: RoutesToRenderer; @property({ type: String }) basePath?: string; protected createRenderRoot(): HTMLElement | DocumentFragment { return this; } connectedCallback() { LocationHandler.onChange(this); super.connectedCallback(); } disconnectedCallback(): void { LocationHandler.offChange(this); super.disconnectedCallback(); } private _location: string = document.location.href.replace( document.location.origin, "" ); @property() set location(value: string) { this._location = value; //keep only the path abnd the has this.requestUpdate(); } get location() { return this._location; } get cleanLocation() { const url = new URL(this.location, document.location.origin); return url.pathname + url.hash; } private get patternBasePath() { if (this.basePath !== undefined) { return this.basePath; } return "(/)*"; } private get regExpBasePath() { if (this.basePath !== undefined) { return "^" + this.basePath; } return ""; } private handleroutes(): DirectiveResult[] { if (!this.routes) return []; const templates: DirectiveResult[] = []; for (let [path, renderFn] of Object.entries(this.routes)) { if (path === "fallback" || !renderFn) continue; const regexp = new RegExp(this.regExpBasePath + path); if (regexp.test(this.cleanLocation)) { const params = regexp.exec(this.cleanLocation) || [path]; params.shift(); templates.push(renderFn([...params])); } else { try { const pattern = new UrlPattern(this.patternBasePath + path); const match = pattern.match(this.cleanLocation); if (match) { const params = match || {}; templates.push(renderFn(params)); } } catch (e) { if ( this.cleanLocation.indexOf( (this.basePath || "") + path.replace(document.location.origin, "") ) != -1 ) { templates.push(renderFn({})); } } } } //Gestion fallback if (templates.length == 0) { if (this.routes?.fallback && this.isConnected) { templates.push(this.routes.fallback()); } } return templates; } render() { const programmaticTemplates = this.handleroutes(); if (programmaticTemplates.length > 0) { return html`${programmaticTemplates}`; } const templates = []; for (const t of this.templatePartsList) { const path = t.getAttribute(this.templateValueAttribute) || ""; const regexp = new RegExp(this.regExpBasePath + path); if (regexp.test(this.cleanLocation)) { templates.push(t); } else { try { if ( new UrlPattern(this.patternBasePath + path + "(/*)").match( this.cleanLocation ) ) { t.setAttribute("mode", "patternMatching"); templates.push(t); } } catch (e) { if ( this.cleanLocation.indexOf( (this.basePath || "") + path.replace(document.location.origin, "") ) != -1 ) { templates.push(t); } } } } if (templates.length == 0) { if (this.fallBackRoute && this.isConnected) { document.location.href = this.fallBackRoute; } const fallback = this.templateList.find((t) => t.hasAttribute("data-fallback") ); if (fallback) { templates.push(fallback); } } return html`${repeat( templates, (template, index) => { template; return index + new Date().getTime(); }, (template) => { if (template.title) document.title = template.title; if (template.hasAttribute("dataProviderExpression")) { let dataProvider = ""; const dataProviderExpression = template.getAttribute("dataProviderExpression") || ""; if (template.getAttribute("mode") == "patternMatching") { const matcher = new UrlPattern( "(/)*" + (template.getAttribute(this.templateValueAttribute) || "") + "*" ); const filler = new UrlPattern(dataProviderExpression); dataProvider = filler.stringify(matcher.match(this.cleanLocation)); } else { const regexp = new RegExp( template.getAttribute(this.templateValueAttribute) || "" ); const match = (this.cleanLocation + "").match(regexp); if (match) { dataProvider = match.shift()?.replace(regexp, dataProviderExpression) || ""; } } return html`
${templateContent(template)}
`; } return templateContent(template); } )}`; } }