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>
@branwyn-tylwyth
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