// TODO: Override the marker.js renderer for urls to enable proper anchor support. class Docs { /** * @param {string} defaultView * @param {string} routesPath */ constructor(defaultViewPath, fetchPath = 'content', routesPath = './assets/data/routes.json') { /** @type {string} */ this.defaultViewPath = defaultViewPath; /** @type {string} */ this.fetchPath = fetchPath; /** @type {string} */ this.routesPath = routesPath; /** @type {Array.} */ this.routesData = []; /** @type {Array.<(Route, RoutingNode)} */ this.routes = []; /** @type {Route} */ this.currentRoute = undefined; this.onExec(); } /** * Called on initialization by the constructor. */ async onExec() { this.routesData = await this.fetchRoutes(); this.updateRoutes(this.routesData); this.generateNav(this.routes, document.querySelector('aside')); window.addEventListener('hashchange', () => this.handleLocation(window.location)); // Check for initial condition. if (window.location.hash) { // There is a hash in the address bar, load the view. this.handleLocation(window.location); } else if (typeof this.defaultViewPath !== 'undefined') { // TODO: Load a default view. console.log('Missing Feature !\nShould load the default view'); } } /** * Fetch the json file containing the routes. * @returns {Array.} The array containing the routes. */ async fetchRoutes() { const response = await fetch(this.routesPath); const json = await response.json(); return json['routes']; } /** * Update the routes. * @param {Array.} routesData */ updateRoutes(routesData) { const routes = []; routesData.forEach(routeData => { if ('routes' in routeData) { const node = new RoutingNode( routeData['name'], routeData['hidden'], routeData['routes'] ); routes.push(node); } else { const route = new Route( routeData['name'], routeData['hidden'], routeData['filepath'] ); routes.push(route); } }); this.routes = routes; } /** * Generate the side nav HTML based on routing data. * @param {Array.} routes * @param {HTMLElement} target */ generateNav(routes, target) { const nav = document.createElement('nav'); routes.forEach(routingItem => { if (routingItem['hidden'] === false || typeof routingItem['hidden'] === 'undefined') { const el = routingItem.buildSelf(); if (typeof el !== 'undefined') { nav.appendChild(el); } } }); target.appendChild(nav); } /** * Handle if the view needs to be changed or to move to in-page anchor * base on the location data. * @param {Location} location */ handleLocation(location) { const hashes = location.hash.split('#'); hashes.splice(0, 1); if (typeof this.currentRoute === 'undefined' || this.currentRoute.filepath !== hashes[0]) { // TODO: add check in case of no matching routes. const route = this.findMatchingRoute(hashes[0], this.routes); this.currentRoute = route; // Scroll back to top of the content outlet. document.querySelector('#router-outlet').scrollTop = 0; this.displayRoute(route); // Changing classes based on the new active route. const activeLinks = document.querySelectorAll('a.active'); const newActiveLinks = document.querySelectorAll(`a[href='#${hashes[0]}']`); if (activeLinks.length > 0) { activeLinks.forEach(link => link.classList.remove('active')); } newActiveLinks.forEach(newLink => newLink.classList.add('active')); if (hashes.length > 1) { this.handleLocation(window.location); } } else { // Page don't need to be changed, just move to anchor. const el = document.querySelector(`#${hashes[1]}`); el.scrollIntoView({ behavior: 'smooth', block: 'start' }); } } /** * Recursively search for a matching route based on a filepath. * @param {string} filepath The filepath to search. * @returns {Route} The matching route. */ findMatchingRoute(filepath, routes) { // TODO: Break the loops in a more elegant way. let match = undefined; for (let routingItem of routes) { // If the routingItem is a Route and have what we are looking for. if (routingItem.hasOwnProperty('filepath') && routingItem['filepath'] === filepath) { match = routingItem; break; } // Recursive search to go deeper. for (let v of Object.keys(routingItem)) { if (typeof routingItem[v] === 'object' && v === 'routes') { const o = this.findMatchingRoute(filepath, routingItem[v]); if (o != null) { match = o; break; } } } } return match; } /** * Load and displays a route's content. * @param {Route} route The route to load. */ async displayRoute(route) { const content = await route.loadSelf(this.fetchPath); const contentOutlet = document.querySelector('#router-outlet'); this.removeChildren(contentOutlet); contentOutlet.innerHTML = marked(content); Prism.highlightAll(); // edited } /** * Utilitary function to remove any content inside an HTML element * @param {HTMLElement} element */ removeChildren(element) { let count = element.childNodes.length; while (count--) { element.removeChild(element.lastChild); } } } class RoutingItem { /** * @param {string} name * @param {boolean} hidden */ constructor(name, hidden) { this.name = name; this.hidden = hidden; } /** * @param {*} force Wether to force generation or not despite the hidden property. * @returns {HTMLElement} The RoutingItem as an HTML element (div). */ buildSelf(force = false) { if (this.hidden === false || typeof this.hidden === 'undefined') { force = true; } if (force) { const element = document.createElement('div'); element.innerText = this.name; return element; } } } class Route extends RoutingItem { /** * @param {string} name * @param {boolean} hidden * @param {string} filepath */ constructor(name, hidden, filepath) { super(name, hidden); /** @type {string} */ this.filepath = filepath; } /** * @param {boolean} force Wether to force generation or not despite the hidden property. * @returns {HTMLAnchorElement} The Route as an HTML link. */ buildSelf(force = false) { if (this.hidden === false || typeof this.hidden === 'undefined') { force = true; } if (force) { const element = document.createElement('a'); element.href = `#${this.filepath}`; element.innerText = this.name; return element; } } /** * Loads the Route content and returns it as text. * @param {string} prefix (optional) An url prefix to add *(i.e: different location)*. * @param {string} suffix (optional) An url suffix to add *(i.e: file extension)*. * @returns {string} The document content to load as a string. */ async loadSelf(prefix, suffix) { if (typeof prefix === 'undefined') { prefix = ''; } else { prefix += '/'; } if (typeof suffix === 'undefined') { suffix = ''; } const response = await fetch(`${prefix}${this.filepath}${suffix}`); const text = await response.text(); return text; } } class RoutingNode extends RoutingItem { /** * @param {string} name * @param {boolean} hidden * @param {Array.} routesData */ constructor(name, hidden, routesData) { super(name, hidden); /** @type {Array.} */ this.routesData = routesData; /** @type {Array.<(Route,RoutingNode)>} */ this.routes = []; this.onExec(); } /** * Called on initialization by the constructor. */ onExec() { this.routesData.forEach(routeData => { if ('routes' in routeData) { this.routes.push(new RoutingNode(routeData['name'], routeData['hidden'], routeData['routes'])); } else { this.routes.push(new Route(routeData['name'], routeData['hidden'], routeData['filepath'])); } }); } /** * Builds the HTML representation of the RoutingNode as an HTML unorded list. * @returns {HTMLUListElement} The RoutingNode as an HTML list. */ buildSelf(force = false) { if (this.hidden === false || typeof this.hidden === 'undefined') { force = true } if (force) { const element = document.createElement('section'); element.classList.add('node', 'hide'); const heading = this.buildSelf_Heading(this.name, toggleDisplay); const list = this.buildSelf_List(this.routes); element.appendChild(heading); element.appendChild(list); return element; } else { return; } } /** * @private * @param {string} text * @param {Function} onClickCallback (optional) */ buildSelf_Heading(text, onClickCallback) { const wrapper = document.createElement('div'); const headingText = document.createElement('span'); const headingIcon = document.createElement('img'); headingText.innerText = text; headingIcon.src = './assets/img/icons/baseline_expand_more_white_24dp.png'; wrapper.appendChild(headingText); wrapper.appendChild(headingIcon); if (typeof onClickCallback === "function") { wrapper.addEventListener('click', onClickCallback); } return wrapper; } /** * @private * @param {Array.<(Route,RoutingNode)>} entries */ buildSelf_List(entries) { const list = document.createElement('ul'); entries.forEach(entry => { const listItem = document.createElement('li'); const itemContent = entry.buildSelf(); if (typeof itemContent !== 'undefined') { listItem.appendChild(itemContent); list.appendChild(listItem); } }); return list; } } // TODO: find them a new home /** * @param {Event} event */ function toggleDisplay(event) { const dropdown = event.currentTarget.parentNode; // const list = dropdown.querySelector('ul'); toggleClass(dropdown, 'hide'); }; /** * @param {HTMLElement} el * @param {string} className */ function toggleClass(el, className) { if (el.classList.contains(className)) { el.classList.remove(className); } else { el.classList.add(className); } };