Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save psycho0verload/b0057b5b3bba480cd8d80c3f4eab6822 to your computer and use it in GitHub Desktop.

Select an option

Save psycho0verload/b0057b5b3bba480cd8d80c3f4eab6822 to your computer and use it in GitHub Desktop.
This script dynamically loads and displays a hierarchical list of subpages for the current page in Wiki.js (version 2.x). It utilizes the built-in GraphQL API to fetch child pages and builds a nested tree view up to a specified depth.

📄 Auto-generate Subpage Tree for Wiki.js 2.x

Description:

This script dynamically loads and displays a hierarchical list of subpages in Wiki.js (version 2.x). It utilizes the built-in GraphQL API (pages.search for subpaths, pages.list for root-level) to fetch pages and builds a nested tree view up to a specified depth.

Usage:

The script can be included in two ways:

  1. Globally via Administration → Theme → Head HTML Injection

Requirements:

A <div class="children-placeholder"> must be present on the page. Configure it with the following optional attributes:

Attribute Default Description
data-limit 100 Maximum number of subpages
data-depth 2 Maximum tree depth
data-sort path:asc Sorting field (path, title, description) and direction (asc, desc)
data-target-path (current page) Override target path. Supports locale prefix (e.g. de/projekte/project01). Set to locale only (e.g. de) to generate a full sitemap from root.
data-debug false Enable debug logs in the browser console

Examples:

Subpages of the current page:

<div class="children-placeholder" data-depth="2"></div>

Subpages of a specific path:

<div class="children-placeholder" data-target-path="de/projekte/project01" data-depth="3"></div>

Full sitemap (all pages):

<div class="children-placeholder" data-target-path="de" data-depth="5" data-limit="200"></div>

Result:

Once loaded, the script replaces the placeholder with a nested <ul> structure of links to all matching subpages.

<script type="application/javascript">
(async function() {
// Wait until Wiki.js finishes rendering the page content
function waitForContent(timeout = 10000) {
return new Promise((resolve) => {
const start = Date.now();
const check = () => {
const el = document.querySelector(".children-placeholder");
if (el) return resolve(el);
if (Date.now() - start > timeout) return resolve(null);
requestAnimationFrame(check);
};
check();
});
}
const placeholder = await waitForContent();
if (!placeholder) {
console.log("🛈 No .children-placeholder found (timeout reached).");
return;
}
// Read configuration from data attributes
const limit = parseInt(placeholder.getAttribute("data-limit") || "100", 10);
const maxDepth = parseInt(placeholder.getAttribute("data-depth") || "1", 10);
const sortAttr = placeholder.getAttribute("data-sort") || "path:asc";
const debug = placeholder.getAttribute("data-debug") === "true";
const targetPath = placeholder.getAttribute("data-target-path");
// Validate sort field against allowed values
const ALLOWED_SORT_FIELDS = ["path", "title", "description"];
const [rawSortField, sortDirection] = sortAttr.split(":");
const sortField = ALLOWED_SORT_FIELDS.includes(rawSortField) ? rawSortField : "path";
const sortAsc = sortDirection !== "desc";
const log = (...args) => debug && console.log(...args);
if (sortField !== rawSortField) {
log("⚠️ Invalid sort field:", rawSortField, "– falling back to 'path'");
}
// Parse current URL for locale and path
let fullPath = window.location.pathname;
let [, rawLocale, ...pathParts] = fullPath.split("/");
// Validate locale: must be 2-3 letter language code (e.g. "en", "de", "deu")
const locale = /^[a-z]{2,3}$/.test(rawLocale) ? rawLocale : "en";
if (locale !== rawLocale) {
log("⚠️ Invalid locale detected:", rawLocale, "– falling back to 'en'");
}
// Use targetPath if specified, otherwise use current path
let path;
if (targetPath) {
path = targetPath.replace(/^\/+|\/+$/g, "");
// Strip locale prefix if present (e.g. "de" → "" or "de/projekte/dana" → "projekte/dana")
if (path === locale) {
path = "";
} else if (path.startsWith(locale + "/")) {
path = path.slice(locale.length + 1);
}
log("🎯 Using target path:", path || "(root)");
} else {
path = pathParts.join("/").replace(/^\/+|\/+$/g, "");
log("📍 Using current path:", path);
}
const isRoot = path === "";
const basePath = isRoot ? "" : `${path}/`;
log("🌍 Locale:", locale);
log("🔍 Searching for subpages of path:", isRoot ? "(root)" : basePath);
placeholder.textContent = "Loading subpages…";
// Build GraphQL query: use pages.list for root level, pages.search for subpaths
let gqlQuery;
if (isRoot) {
gqlQuery = {
query: `
query {
pages {
list(orderBy: PATH) {
path
title
description
}
}
}
`
};
log("📋 Using pages.list (root level)");
} else {
gqlQuery = {
query: `
query ($query: String!, $locale: String!) {
pages {
search(query: $query, locale: $locale) {
results {
title
path
description
}
}
}
}
`,
variables: { query: basePath, locale: locale }
};
log("🔎 Using pages.search for:", basePath);
}
try {
const response = await fetch("/graphql", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(gqlQuery)
});
const json = await response.json();
if (!response.ok || json.errors) {
throw new Error("GraphQL error: " + JSON.stringify(json.errors));
}
// Extract results depending on which query was used
const results = isRoot
? (json?.data?.pages?.list ?? [])
: (json?.data?.pages?.search?.results ?? []);
log("📄 Found pages:", results.map(p => p.path));
// Filter for children
const children = results
.filter(p => p.path !== path)
.filter(p => isRoot || p.path.startsWith(path + "/"))
.sort((a, b) => {
const aVal = a[sortField]?.toLowerCase?.() || "";
const bVal = b[sortField]?.toLowerCase?.() || "";
if (aVal < bVal) return sortAsc ? -1 : 1;
if (aVal > bVal) return sortAsc ? 1 : -1;
return 0;
})
.slice(0, limit);
log("✅ Filtered & sorted subpages:", children.map(p => p.path));
if (children.length === 0) {
placeholder.innerHTML = "<em>No subpages available.</em>";
return;
}
// Build a tree
const tree = {};
children.forEach(page => {
const relPath = isRoot
? page.path.replace(/^\/+|\/+$/g, "")
: page.path.slice(basePath.length).replace(/^\/+|\/+$/g, "");
const parts = relPath.split("/");
let node = tree;
parts.forEach((part, idx) => {
if (!node[part]) node[part] = { __meta: null, __children: {} };
if (idx === parts.length - 1) node[part].__meta = page;
node = node[part].__children;
});
});
function escapeHtml(str) {
return str.replace(/[&<>"']/g, m =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#039;" }[m])
);
}
function renderTree(treeObj, depth = 1) {
if (depth > maxDepth) return null;
const ul = document.createElement("ul");
ul.className = `children-tree level-${depth}`;
for (const key of Object.keys(treeObj)) {
const node = treeObj[key];
const hasChildren = Object.keys(node.__children).length > 0;
const hasMeta = !!node.__meta;
if (!hasMeta && !hasChildren) continue;
const li = document.createElement("li");
li.className = "children-item";
if (hasMeta) {
const p = node.__meta;
li.innerHTML = `
<a href="/${locale}/${p.path}">${escapeHtml(p.title)}</a>
<br><small>${escapeHtml(p.description || "")}</small>
`;
} else {
li.innerHTML = `<strong>${escapeHtml(key)}</strong>`;
}
const childList = renderTree(node.__children, depth + 1);
if (childList) li.appendChild(childList);
ul.appendChild(li);
}
return ul;
}
// Render final tree
const wrapper = document.createElement("div");
wrapper.className = "children-list";
const treeHtml = renderTree(tree);
if (treeHtml) wrapper.appendChild(treeHtml);
// Safely replace content inside placeholder
placeholder.innerHTML = "";
placeholder.appendChild(wrapper);
log("🌲 Tree structure successfully rendered.");
} catch (err) {
console.error("❌ Error loading subpages:", err);
placeholder.innerHTML = "<em>Error loading subpages.</em>";
}
})();
</script>
@mormorhaxa
Copy link

@psycho0verload Great! I’ll check it out!

@mormorhaxa
Copy link

I’ve actually got a newer version I’m using myself that’s easier for my users to use with the Visual Editor.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment