mirror of
				https://github.com/zadam/trilium.git
				synced 2025-10-31 10:26:08 +01:00 
			
		
		
		
	Generate ToC in templates
This commit is contained in:
		| @@ -10,7 +10,7 @@ | |||||||
|     "build-styles": "esrun scripts/build.ts -- --module=styles", |     "build-styles": "esrun scripts/build.ts -- --module=styles", | ||||||
|     "templates": "esrun scripts/build.ts -- --only-templates", |     "templates": "esrun scripts/build.ts -- --only-templates", | ||||||
|     "dist": "esrun scripts/build.ts -- --minify", |     "dist": "esrun scripts/build.ts -- --minify", | ||||||
|     "test": "echo \"Error: no test specified\" && exit 1" |     "test": "esrun src/scripts/test.ts" | ||||||
|   }, |   }, | ||||||
|   "author": "", |   "author": "", | ||||||
|   "license": "ISC", |   "license": "ISC", | ||||||
|   | |||||||
| @@ -20,13 +20,14 @@ if (process.env.TRILIUM_ETAPI_TOKEN) tepi.token(process.env.TRILIUM_ETAPI_TOKEN) | |||||||
|  |  | ||||||
|  |  | ||||||
| const templateMap: Record<string, string> = { | const templateMap: Record<string, string> = { | ||||||
|     "src/templates/page.ejs": process.env.PAGE_TEMPLATE_ID!, |     page: process.env.PAGE_TEMPLATE_ID!, | ||||||
|     "src/templates/tree_item.ejs": process.env.ITEM_TEMPLATE_ID!, |     tree_item: process.env.ITEM_TEMPLATE_ID!, | ||||||
|  |     toc_item: process.env.TOC_TEMPLATE_ID!, | ||||||
| }; | }; | ||||||
|  |  | ||||||
| async function sendTemplates() { | async function sendTemplates() { | ||||||
|     for (const template in templateMap) { |     for (const template in templateMap) { | ||||||
|         const templatePath = path.join(rootDir, template); |         const templatePath = path.join(rootDir, "src", "templates", `${template}.ejs`); | ||||||
|         const contents = fs.readFileSync(templatePath).toString(); |         const contents = fs.readFileSync(templatePath).toString(); | ||||||
|         await tepi.putNoteContentById(templateMap[template], contents); |         await tepi.putNoteContentById(templateMap[template], contents); | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -57,8 +57,6 @@ function $try<T extends (...a: unknown[]) => unknown>(func: T, ...args: Paramete | |||||||
| // $try(buildBreadcrumbs); | // $try(buildBreadcrumbs); | ||||||
| // $try(buildSidenav); | // $try(buildSidenav); | ||||||
|  |  | ||||||
| // TODO: Determine the difficulty of adding this in |  | ||||||
| // trilium directly using JSDOM. |  | ||||||
| $try(setupToC); | $try(setupToC); | ||||||
|  |  | ||||||
| // Finally, other features | // Finally, other features | ||||||
|   | |||||||
| @@ -1,99 +1,34 @@ | |||||||
| const slugify = (text: string) => text.toLowerCase().replace(/[^\w]/g, "-"); |  | ||||||
|  |  | ||||||
| const getDepth = (el: Element) => parseInt(el.tagName.replace("H","").replace("h","")); |  | ||||||
|  |  | ||||||
| const buildItem = (heading: Element) => { |  | ||||||
|     const slug = slugify(heading.textContent ?? ""); |  | ||||||
|  |  | ||||||
|     const anchor = document.createElement("a"); |  | ||||||
|     anchor.className = "toc-anchor"; |  | ||||||
|     anchor.setAttribute("href", `#${slug}`); |  | ||||||
|     anchor.setAttribute("name", slug); |  | ||||||
|     anchor.setAttribute("id", slug); |  | ||||||
|     anchor.textContent = "#"; |  | ||||||
|  |  | ||||||
|     const link = document.createElement("a"); |  | ||||||
|     link.setAttribute("href", `#${slug}`); |  | ||||||
|     link.textContent = heading.textContent; |  | ||||||
|     link.addEventListener("click", e => { |  | ||||||
|         const target = document.querySelector(`#${slug}`); |  | ||||||
|         if (!target) return; |  | ||||||
|  |  | ||||||
|         e.preventDefault(); |  | ||||||
|         e.stopPropagation(); |  | ||||||
|          |  | ||||||
|         target.scrollIntoView({behavior: "smooth"}); |  | ||||||
|     }); |  | ||||||
|  |  | ||||||
|     heading.append(anchor); |  | ||||||
|  |  | ||||||
|     const li = document.createElement("li"); |  | ||||||
|     li.append(link); |  | ||||||
|     return li; |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * Generate a ToC from all heading elements in the main content area. |  * The ToC is now generated in the page template so | ||||||
|  * This should go to full h6 depth and not be too opinionated. It  |  * it even exists for users without client-side js | ||||||
|  * does assume a "sensible" structure in that you don't go from |  * and that means it loads with the page so it avoids | ||||||
|  * h2 > h4 > h1 but rather h2 > h3 > h2 so you change by 1 and end |  * all potential reshuffling or layout recalculations. | ||||||
|  * up at the same level as before. |  *  | ||||||
|  |  * So, all this function needs to do is make the links | ||||||
|  |  * perform smooth animation, and adjust the "active" | ||||||
|  |  * entry as the user scrolls. | ||||||
|  */ |  */ | ||||||
| export default function setupToC() { | export default function setupToC() { | ||||||
|     // Get all headings from the page and map them to already built elements |     const toc = document.getElementById("toc"); | ||||||
|     const headings = Array.from(document.querySelectorAll("h1, h2, h3, h4, h5, h6")); |     if (!toc) return; | ||||||
|     if (headings.length <= 1) return; // But if there are none, let's do nothing |  | ||||||
|     const items = headings.map(h => buildItem(h)); |  | ||||||
|      |  | ||||||
|     // Setup the ToC list |  | ||||||
|     const toc = document.createElement("ul"); |  | ||||||
|     toc.id = "toc"; |  | ||||||
|  |  | ||||||
|     // Get the depth of the first content heading on the page. |     // Get all relevant elements | ||||||
|     // This depth will be used as reference for all other headings. |     const sections = document.getElementById("content")!.querySelectorAll("h2, h3, h4, h5, h6"); | ||||||
|     // headings[0] === the <h1> from Trilium |     const links = toc.querySelectorAll("a"); | ||||||
|     const firstDepth = getDepth(headings[1]); |  | ||||||
|  |  | ||||||
|     // Loop over ALL headings including the first |     // Setup smooth scroll on click | ||||||
|     for (let h = 0; h < headings.length; h++) { |     for (const link of links) { | ||||||
|         // Get current heading and determine depth |         link.addEventListener("click", e => { | ||||||
|         const current = headings[h]; |             const target = document.querySelector(link.getAttribute("href")!); | ||||||
|         const currentDepth = getDepth(current); |             if (!target) return; | ||||||
|  |             e.preventDefault(); | ||||||
|         // If it's the same depth as our first heading, add to ToC |             e.stopPropagation(); | ||||||
|         if (currentDepth === firstDepth) toc.append(items[h]); |              | ||||||
|  |             target.scrollIntoView({behavior: "smooth"}); | ||||||
|         // If this is the last element then it will have already |         }); | ||||||
|         // been added as a child or as same depth as first |  | ||||||
|         let nextIndex = h + 1; |  | ||||||
|         if (nextIndex >= headings.length) continue; |  | ||||||
|  |  | ||||||
|         // Time to find all children of this heading |  | ||||||
|         const children = []; |  | ||||||
|         const childDepth = currentDepth + 1; |  | ||||||
|         let depthOfNext = getDepth(headings[nextIndex]); |  | ||||||
|         while (depthOfNext > currentDepth) { |  | ||||||
|             // If it's the expected depth, add as child |  | ||||||
|             if (depthOfNext === childDepth) children.push(nextIndex); |  | ||||||
|             nextIndex++; |  | ||||||
|  |  | ||||||
|             // If the next index is valid, grab the depth for next loop |  | ||||||
|             // TODO: could this be done cleaner with a for loop? |  | ||||||
|             if (nextIndex < headings.length) depthOfNext = getDepth(headings[nextIndex]); |  | ||||||
|             else depthOfNext = currentDepth; // If the index was invalid, break loop |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         // If this heading had children, add them as children |  | ||||||
|         if (children.length) { |  | ||||||
|             const ul = document.createElement("ul"); |  | ||||||
|             for (const c of children) ul.append(items[c]); |  | ||||||
|             items[h].append(ul); |  | ||||||
|         } |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     // Setup a moving "active" in the ToC that adjusts with the scroll state |     // Setup a moving "active" in the ToC that adjusts with the scroll state | ||||||
|     const sections = headings.slice(1); |  | ||||||
|     const links = toc.querySelectorAll("a"); |  | ||||||
|     function changeLinkState() { |     function changeLinkState() { | ||||||
|         let index = sections.length; |         let index = sections.length; | ||||||
|  |  | ||||||
| @@ -108,19 +43,4 @@ export default function setupToC() { | |||||||
|     // Initial render |     // Initial render | ||||||
|     changeLinkState(); |     changeLinkState(); | ||||||
|     window.addEventListener("scroll", changeLinkState); |     window.addEventListener("scroll", changeLinkState); | ||||||
|  |  | ||||||
|     // Create the toc wrapper |  | ||||||
|     const pane = document.createElement("div"); |  | ||||||
|     pane.id = "toc-pane"; |  | ||||||
|      |  | ||||||
|     // Create the header |  | ||||||
|     const header = document.createElement("h3"); |  | ||||||
|     header.textContent = "On This Page"; |  | ||||||
|     pane.append(header); |  | ||||||
|     pane.append(toc); |  | ||||||
|  |  | ||||||
|     // Finally, add the ToC to the end of layout. Give the layout a class for adjusting widths. |  | ||||||
|     const layout = document.querySelector("#right-pane"); |  | ||||||
|     layout?.classList.add("toc"); |  | ||||||
|     layout?.append(pane); |  | ||||||
| } | } | ||||||
							
								
								
									
										83
									
								
								src/scripts/test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										83
									
								
								src/scripts/test.ts
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| @@ -14,6 +14,10 @@ | |||||||
|     border-top: 1px solid var(--background-highlight); |     border-top: 1px solid var(--background-highlight); | ||||||
| } | } | ||||||
|  |  | ||||||
|  | .no-content + #childLinks { | ||||||
|  |     border: 0; | ||||||
|  | } | ||||||
|  |  | ||||||
| #childLinks ul { | #childLinks ul { | ||||||
|     padding: 0; |     padding: 0; | ||||||
|     gap: 10px; |     gap: 10px; | ||||||
| @@ -22,32 +26,31 @@ | |||||||
| } | } | ||||||
|  |  | ||||||
| #childLinks li { | #childLinks li { | ||||||
|     background: var(--background-highlight); |  | ||||||
|     padding: 2px 12px; |  | ||||||
|     border-radius: 12px; |  | ||||||
| } |  | ||||||
|  |  | ||||||
|  |  | ||||||
| .no-content + #childLinks { |  | ||||||
|     border: 0; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| #childLinks.grid li { |  | ||||||
|     padding: 0; |     padding: 0; | ||||||
|  |     background: var(--background-highlight); | ||||||
|  |     border-radius: 12px; | ||||||
| } | } | ||||||
|  |  | ||||||
| #childLinks.grid li a { | #childLinks li a { | ||||||
|     padding: 50px; |     padding: 2px 12px; | ||||||
|     border-radius: 12px; |  | ||||||
|     background: var(--background-highlight); |     background: var(--background-highlight); | ||||||
|     color: var(--text-primary); |     border-radius: 12px; | ||||||
|     transform: translateY(0); |     transform: translateY(0); | ||||||
|     transition: transform 200ms ease, background-color 200ms ease, color 200ms ease; |     transition: transform 200ms ease, background-color 200ms ease, color 200ms ease; | ||||||
| } | } | ||||||
|  |  | ||||||
| #childLinks.grid li a:hover { | #childLinks li a:hover { | ||||||
|     background: var(--background-active); |     background: var(--background-active); | ||||||
|     color: var(--background-secondary); |     color: var(--background-secondary); | ||||||
|     text-decoration: none; |     text-decoration: none; | ||||||
|  |     transform: translateY(-2px); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #childLinks.grid li a { | ||||||
|  |     padding: 50px; | ||||||
|  |     color: var(--text-primary); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | #childLinks.grid li a:hover { | ||||||
|     transform: translateY(-5px); |     transform: translateY(-5px); | ||||||
| } | } | ||||||
| @@ -4,7 +4,7 @@ a[href^="https://"] { | |||||||
|     gap: 6px; |     gap: 6px; | ||||||
| } | } | ||||||
|  |  | ||||||
| #main a[href^="https://"] { | #content a[href^="https://"] { | ||||||
|     padding-right: 6px; |     padding-right: 6px; | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -87,6 +87,13 @@ const customServerYml = `- url: "{protocol}://{domain}:{port}/etapi" | |||||||
| <% | <% | ||||||
| const currentTheme = note.getLabel("shareTheme") === "light" ? "light" : "dark"; | const currentTheme = note.getLabel("shareTheme") === "light" ? "light" : "dark"; | ||||||
| const themeClass = currentTheme === "light" ? " theme-light" : " theme-dark"; | const themeClass = currentTheme === "light" ? " theme-light" : " theme-dark"; | ||||||
|  | const headingRe = /(<h[1-6]>)(.+?)(<\/h[1-6]>)/g; | ||||||
|  | const headingMatches = [...content.matchAll(headingRe)]; | ||||||
|  | const slugify = (text) => text.toLowerCase().replace(/[^\w]/g, "-"); | ||||||
|  | content = content.replaceAll(headingRe, (...match) => { | ||||||
|  |     match[0] = match[0].replace(match[3], `<a id="${slugify(match[2])}" class="toc-anchor" name="${slugify(match[2])}" href="#${slugify(match[2])}">#</a>${match[3]}`); | ||||||
|  |     return match[0]; | ||||||
|  | }); | ||||||
| %> | %> | ||||||
| <body data-note-id="<%= note.noteId %>" class="type-<%= note.type %><%= themeClass %>"> | <body data-note-id="<%= note.noteId %>" class="type-<%= note.type %><%= themeClass %>"> | ||||||
| <div id="mobile-header"> | <div id="mobile-header"> | ||||||
| @@ -151,16 +158,56 @@ const themeClass = currentTheme === "light" ? " theme-light" : " theme-dark"; | |||||||
|                         <% |                         <% | ||||||
|                         const action = note.type === "book" ? "getChildNotes" : "getVisibleChildNotes"; |                         const action = note.type === "book" ? "getChildNotes" : "getVisibleChildNotes"; | ||||||
|                         for (const childNote of note[action]()) { |                         for (const childNote of note[action]()) { | ||||||
|  |                             const isExternalLink = childNote.hasLabel("shareExternal"); | ||||||
|  |                             const linkHref = isExternalLink ? childNote.getLabelValue("shareExternal") : `./${childNote.shareId}`; | ||||||
|  |                             const target = isExternalLink ? ` target="_blank" rel="noopener noreferrer"` : ""; | ||||||
|                         %> |                         %> | ||||||
|                             <li> |                             <li> | ||||||
|                                 <a href="<%= childNote.shareId %>" |                                 <a class="type-<%= childNote.type %>" href="<%= linkHref %>"<%= target %>><%= childNote.title %></a> | ||||||
|                                 class="type-<%= childNote.type %>"><%= childNote.title %></a> |  | ||||||
|                             </li> |                             </li> | ||||||
|                         <% } %> |                         <% } %> | ||||||
|                     </ul> |                     </ul> | ||||||
|                 </nav> |                 </nav> | ||||||
|             <% } else %> |             <% } %> | ||||||
|         </div> |         </div> | ||||||
|  |         <% | ||||||
|  |         if (headingMatches.length > 1) { | ||||||
|  |             const level = (m) => parseInt(m[1].replace(/[<h>]+/g, "")); | ||||||
|  |  | ||||||
|  |             const toc = [ | ||||||
|  |                 { | ||||||
|  |                     level: level(headingMatches[0]), | ||||||
|  |                     name: headingMatches[0][2], | ||||||
|  |                     children: [] | ||||||
|  |                 } | ||||||
|  |             ]; | ||||||
|  |             const last = (arr = toc) => arr[arr.length - 1]; | ||||||
|  |             const makeEntry = (m) => ({level: level(m), name: m[2], children: []}); | ||||||
|  |             const getLevelArr = (lvl, arr = toc) => { | ||||||
|  |                 if (arr[0].level === lvl) return arr; | ||||||
|  |                 const top = last(arr); | ||||||
|  |                 return top.children.length ? getLevelArr(lvl, top.children) : top.children; | ||||||
|  |             }; | ||||||
|  |              | ||||||
|  |              | ||||||
|  |             for (let m = 1; m < headingMatches.length; m++) { | ||||||
|  |                 const target = getLevelArr(level(headingMatches[m])); | ||||||
|  |                 target.push(makeEntry(headingMatches[m])); | ||||||
|  |             } | ||||||
|  |         %> | ||||||
|  |             <div id="toc-pane"> | ||||||
|  |                 <h3>On This Page</h3> | ||||||
|  |                 <ul id="toc"> | ||||||
|  |                     <% | ||||||
|  |                     let active = true; | ||||||
|  |                     for (const entry of toc) { | ||||||
|  |                     %> | ||||||
|  |                         <%- include('toc_item', {entry, active}) %> | ||||||
|  |                         <% active = false %> | ||||||
|  |                     <% } %> | ||||||
|  |                 </ul> | ||||||
|  |             </div> | ||||||
|  |         <% } %> | ||||||
|     </div> |     </div> | ||||||
| </div> | </div> | ||||||
| </body> | </body> | ||||||
|   | |||||||
							
								
								
									
										19
									
								
								src/templates/toc_item.ejs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								src/templates/toc_item.ejs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | |||||||
|  | <% | ||||||
|  | const slugify = (text) => text.toLowerCase().replace(/[^\w]/g, "-"); | ||||||
|  | const slug = slugify(entry.name); | ||||||
|  | %> | ||||||
|  |  | ||||||
|  |  | ||||||
|  | <li> | ||||||
|  |     <a href="#<%= slug %>"<% if (active) { %> class="active"<% } %>> | ||||||
|  |         <%= entry.name %> | ||||||
|  |     </a> | ||||||
|  |  | ||||||
|  |     <% if (entry.children.length) { %> | ||||||
|  |         <ul> | ||||||
|  |             <% for (const subentry of entry.children) { %> | ||||||
|  |                 <%- include('toc_item', {entry: subentry, active: false}) %> | ||||||
|  |             <% } %> | ||||||
|  |         </ul> | ||||||
|  |     <% } %> | ||||||
|  | </li> | ||||||
		Reference in New Issue
	
	Block a user