From 71d6311e49c9ba61347e72760cb32fa833b9a99a Mon Sep 17 00:00:00 2001 From: trilium-share-gruvbox Date: Fri, 28 Aug 2026 16:54:32 +0200 Subject: [PATCH] SEO: custom share template (canonical, per-page OG, JSON-LD, robots), robots.txt, OG banner; generator sets shareOpenGraphURL; ignis module STEP example --- AGENTS.md | 8 + README.md | 13 ++ assets/og-banner.png | Bin 0 -> 24117 bytes blog_generator.js | 17 ++- generated/robots.txt | 3 + templates/share/page.ejs | 266 ++++++++++++++++++++++++++++++++++ templates/share/prev_next.ejs | 62 ++++++++ templates/share/toc_item.ejs | 18 +++ templates/share/tree_item.ejs | 34 +++++ 9 files changed, 416 insertions(+), 5 deletions(-) create mode 100644 assets/og-banner.png create mode 100644 generated/robots.txt create mode 100644 templates/share/page.ejs create mode 100644 templates/share/prev_next.ejs create mode 100644 templates/share/toc_item.ejs create mode 100644 templates/share/tree_item.ejs diff --git a/AGENTS.md b/AGENTS.md index e39cd57..6a00798 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,6 +91,14 @@ CSS file (Gruvbox, light + dark, theme toggle without reload). `_thumb` child-note convention for small thumbnails. - Built-in share footer navigation and book children list are hidden (`#content-footer .navigation`, `#index`). +- **SEO**: a custom share template (`templates/share/page.ejs`, note `share template`, + `application/x-ejs`, wired via `~shareTemplate` inheritable on the blog root, with + `tree_item`/`prev_next`/`toc_item` partials as children) adds per-page canonical, + `og:url`/`og:image`/`og:type`, Twitter cards, JSON-LD (WebSite/BlogPosting/CollectionPage) + and `index,follow`. The generator's `ensureSeoMeta()` sets `shareDescription` + + `shareOpenGraphURL` on every published note and category. `robots.txt` + (`shareAlias=robots.txt`, `shareRaw`) points at the sitemap; `~shareOpenGraphImage` + on the blog root sets the default OG image. ## Limitations / gotchas diff --git a/README.md b/README.md index 29dccff..a4c2d60 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,10 @@ no external dependencies (except `highlight.js`, vendored in `lib/`). - **Digital Garden**: `state` labels (Seed → Evergreen, Draft, Review) rendered as badges and colour-coded growth tables. - **Search** (client-side over a generated index), **RSS feed** and **sitemap.xml**. +- **SEO**: per-page canonical + Open Graph (`og:url`, `og:image`, article/website type), + Twitter cards, JSON-LD structured data (WebSite / BlogPosting / CollectionPage), + `robots.txt` and `index,follow` meta. Provided by a custom share template + (`templates/share/page.ejs`). - **Widget rendering** for Mermaid, Excalidraw (canvas) and MindElixir (mind map) notes, with a collapsible, highlighted "Source" block and copy button. - **3D models**: `.step` / `.stp` / `.stl` files (as file notes or inline attachments) render @@ -63,6 +67,8 @@ no external dependencies (except `highlight.js`, vendored in `lib/`). | `lib/hljs_line_numbers.js` | highlight.js line-numbers plugin | code | | `scripts/*.js` | frontend share snippets (content is pasted **verbatim** into a code note, including the ` + <% } %> + <% if (note.hasLabel("shareDisallowRobotIndexing")) { %> + + <% } else { %> + + <% } %> + + <% + const pageTitle = `${note.title}${note.noteId !== subRoot.note.noteId ? ` - ${subRoot.note.title}` : ""}`; + + const openGraphColor = subRoot.note.getLabelValue("shareOpenGraphColor"); + const openGraphDomain = subRoot.note.getLabelValue("shareOpenGraphDomain"); + %> + <%= pageTitle %> + <% if (pageUrl) { %><% } %> + + + "> + + + "> + + + "> + + + + + + + + "> + + + + + <%- renderSnippets("head:end") %> + +<% +const logoWidth = subRoot.note.getLabelValue("shareLogoWidth") ?? 53; +const logoHeight = subRoot.note.getLabelValue("shareLogoHeight") ?? 40; +const mobileLogoHeight = logoHeight && logoWidth ? 32 / (logoWidth / logoHeight) : ""; +const shareRootLink = subRoot.note.hasLabel("shareRootLink") ? sanitizeUrl(subRoot.note.getLabelValue("shareRootLink") ?? "") : `./${subRoot.note.noteId}`; +const headingRe = /()(.+?)(<\/h[1-6]>)/g; +const headingMatches = [...content.matchAll(headingRe)]; +const headingSlugs = utils.slugifyHeadings(headingMatches.map((m) => m[2])); +let headingIndex = 0; +content = content.replaceAll(headingRe, (...match) => { + const slug = headingSlugs[headingIndex++]; + match[0] = match[0].replace(match[3], `#${match[3]}`); + return match[0]; +}); +%> + +<%- renderSnippets("body:start") %> + +
+
+ +
+
+
+
ck-content<% } %><% if (isEmpty) { %> no-content<% } %>"> + <%- renderSnippets("content:start") %> +

<%= note.title %>

+ <% if (isEmpty && (!note.hasVisibleChildren() && note.type !== "book")) { %> +

<%= t("share_page.no-content") %>

+ <% } else { %> + <% + content = content.replace(/${t( + <%- content %> + <% } %> + <%- renderSnippets("content:end") %> +
+ + <% if (note.hasVisibleChildren() || note.type === "book") { %> + + <% } %> + +
+ <% if (!isEmpty && !isStatic) { %> +
+ <% const lastUpdated = new Date(note.utcDateModified); %> + <%- t("share_theme.last-updated", { date: ``}) %> +
+ <% } %> + + <% if (hasTree) { %> + <%- include("prev_next", { note: note, subRoot: subRoot }) %> + <% } %> + + <% if (showLoginInShareTheme && !isStatic) { %> + + <% } %> +
+
+ <% + if (headingMatches.length > 1) { + const level = (m) => parseInt(m[1].replace(/[]+/g, "")); + + const makeEntry = (i) => ({level: level(headingMatches[i]), name: headingMatches[i][2], slug: headingSlugs[i], children: []}); + const toc = [ makeEntry(0) ]; + const last = (arr = toc) => arr[arr.length - 1]; + 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(m)); + } + %> +
+

<%= t("share_theme.on-this-page") %>

+
    + <% for (const entry of toc) { %> + <%- include("toc_item", {entry}) %> + <% } %> +
+
+ <% } %> +
+
+<%- renderSnippets("body:end") %> + + \ No newline at end of file diff --git a/templates/share/prev_next.ejs b/templates/share/prev_next.ejs new file mode 100644 index 0000000..b9df507 --- /dev/null +++ b/templates/share/prev_next.ejs @@ -0,0 +1,62 @@ +<% + // TODO: code cleanup + putting this behind a toggle/attribute + const previousNote = (() => { + // If we are at the subRoot, there is no previous + if (note.noteId === subRoot.note.noteId) return null; + + const parent = note.getParentNotes()[0]; + const children = parent.getVisibleChildNotes(); + const index = children.findIndex(n => n.noteId === note.noteId); + + // If we are the first child, previous goes up a level + // this is already protected by the first if statement + if (index === 0) return parent; + + // We are not the first child at this level so previous + // should go to the end of the previous tree + let candidate = children[index - 1]; + while (candidate?.hasVisibleChildren()) { + const visibleChildren = candidate.getVisibleChildNotes(); + + if (visibleChildren.length === 0) { + break; + } + + candidate = visibleChildren[visibleChildren.length - 1]; + } + + return candidate ?? null; + })(); + + const nextNote = (() => { + // If this currently active note has children, next + // should be the first child + if (note.hasVisibleChildren()) return note.getVisibleChildNotes()[0]; + + let parent = note.getParentNotes()[0]; + let children = parent.getVisibleChildNotes(); + let index = children.findIndex(n => n.noteId === note.noteId); + + // If we are not the last of the current level, just go + // to the next sibling note + if (index !== children.length - 1) return children[index + 1]; + + // We are the last sibling, we need to find the next ancestral note + while (index === children.length - 1) { + // If we are already at subRoot level, no reason trying to go higher + if (parent.noteId === subRoot.note.noteId) return null; + + const originalParent = parent; + parent = parent.getParentNotes()[0]; + children = parent.getVisibleChildNotes(); + index = children.findIndex(n => n.noteId === originalParent.noteId); + } + + return children[index + 1]; + })(); +%> + + diff --git a/templates/share/toc_item.ejs b/templates/share/toc_item.ejs new file mode 100644 index 0000000..5890e0b --- /dev/null +++ b/templates/share/toc_item.ejs @@ -0,0 +1,18 @@ +<% +const strippedName = utils.stripTags(entry.name); +const slug = entry.slug ?? utils.slugify(strippedName); +%> + +
  • + + <%= strippedName %> + + + <% if (entry.children.length) { %> +
      + <% for (const subentry of entry.children) { %> + <%- include('toc_item', {entry: subentry}) %> + <% } %> +
    + <% } %> +
  • diff --git a/templates/share/tree_item.ejs b/templates/share/tree_item.ejs new file mode 100644 index 0000000..9345032 --- /dev/null +++ b/templates/share/tree_item.ejs @@ -0,0 +1,34 @@ +<% +const linkClass = `type-${note.type}` + (activeNote.noteId === note.noteId ? " active" : ""); +const isExternalLink = note.hasLabel("shareExternal"); +let linkHref; + +if (isExternalLink) { + linkHref = sanitizeUrl(note.getLabelValue("shareExternal") ?? ""); +} else if (note.shareId) { + linkHref = `./${note.shareId}`; +} +%> + + <% if (note.noteId !== subRoot.note.noteId) { %> + target="_blank" rel="noopener noreferrer"<% } %>> + <% if (note.hasVisibleChildren()) { %><% } %> + <%= note.title %> + +<% } %> + + +<% if (note.hasVisibleChildren()) { %> + +<% } %>