diff --git a/package.json b/package.json index fa3a380f..c5c0aef5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "codex.docs", "license": "Apache-2.0", - "version": "2.3.0", + "version": "2.3.1", "type": "module", "bin": { "codex.docs": "dist/backend/app.js" diff --git a/src/backend/build-static.ts b/src/backend/build-static.ts index 019c6397..096c6050 100644 --- a/src/backend/build-static.ts +++ b/src/backend/build-static.ts @@ -14,6 +14,7 @@ import appConfig from './utils/appConfig.js'; import Aliases from './controllers/aliases.js'; import Pages from './controllers/pages.js'; import { downloadFavicon } from './utils/downloadFavicon.js'; +import { buildPageBreadcrumbs } from './utils/breadcrumbs.js'; /** * Build static pages from database @@ -96,7 +97,7 @@ export default async function buildStatic(): Promise { if (!pageUri) { throw new Error('Page uri is not defined'); } - const pageParent = await page.getParent(); + const breadcrumbItems = await buildPageBreadcrumbs(page); const pageId = page._id; if (!pageId) { @@ -108,7 +109,7 @@ export default async function buildStatic(): Promise { const menu = createMenuTree(parentIdOfRootPages, allPages, pagesOrder); const result = await renderTemplate('./views/pages/page.twig', { page, - pageParent, + breadcrumbItems, previousPage, nextPage, menu, diff --git a/src/backend/models/page.ts b/src/backend/models/page.ts index ee767355..619044e9 100644 --- a/src/backend/models/page.ts +++ b/src/backend/models/page.ts @@ -137,6 +137,29 @@ class Page { return new Page(data); } + /** + * Ancestors from root to immediate parent (for breadcrumbs). Omits virtual root id "0". + */ + public async getAncestorChain(): Promise { + const chain: Page[] = []; + let parentId = this._parent; + + while (parentId && !isEqualIds(parentId, '0' as EntityId)) { + const data = await pagesDb.findOne({ _id: parentId }); + + if (!data?._id) { + break; + } + + const ancestor = new Page(data); + + chain.unshift(ancestor); + parentId = ancestor._parent; + } + + return chain; + } + /** * Return child pages models * diff --git a/src/backend/routes/aliases.ts b/src/backend/routes/aliases.ts index 1026597e..d4eaeff9 100644 --- a/src/backend/routes/aliases.ts +++ b/src/backend/routes/aliases.ts @@ -5,6 +5,7 @@ import Alias from '../models/alias.js'; import verifyToken from './middlewares/token.js'; import PagesFlatArray from '../models/pagesFlatArray.js'; import HttpException from '../exceptions/httpException.js'; +import { buildPageBreadcrumbs } from '../utils/breadcrumbs.js'; const router = express.Router(); @@ -33,14 +34,14 @@ router.get('*', verifyToken, async (req: Request, res: Response) => { case Alias.types.PAGE: { const page = await Pages.get(alias.id); - const pageParent = await page.getParent(); + const breadcrumbItems = await buildPageBreadcrumbs(page); const previousPage = await PagesFlatArray.getPageBefore(alias.id); const nextPage = await PagesFlatArray.getPageAfter(alias.id); res.render('pages/page', { page, - pageParent, + breadcrumbItems, previousPage, nextPage, config: req.app.locals.config, diff --git a/src/backend/routes/pages.ts b/src/backend/routes/pages.ts index 001211da..9012a175 100644 --- a/src/backend/routes/pages.ts +++ b/src/backend/routes/pages.ts @@ -5,6 +5,7 @@ import verifyToken from './middlewares/token.js'; import allowEdit from './middlewares/locals.js'; import PagesFlatArray from '../models/pagesFlatArray.js'; import { toEntityId } from '../database/index.js'; +import { buildPageBreadcrumbs } from '../utils/breadcrumbs.js'; const router = express.Router(); @@ -62,14 +63,14 @@ router.get('/page/:id', verifyToken, async (req: Request, res: Response, next: N try { const page = await Pages.get(pageId); - const pageParent = await page.parent; + const breadcrumbItems = await buildPageBreadcrumbs(page); const previousPage = await PagesFlatArray.getPageBefore(pageId); const nextPage = await PagesFlatArray.getPageAfter(pageId); res.render('pages/page', { page, - pageParent, + breadcrumbItems, config: req.app.locals.config, previousPage, nextPage, diff --git a/src/backend/utils/breadcrumbs.ts b/src/backend/utils/breadcrumbs.ts new file mode 100644 index 00000000..ad039b26 --- /dev/null +++ b/src/backend/utils/breadcrumbs.ts @@ -0,0 +1,49 @@ +import Page from '../models/page.js'; + +type BreadcrumbItem = { + title: string; + href: string | null; + isEllipsis?: boolean; +}; + +function hrefForPage(p: Page): string { + if (p.uri) { + return `/${p.uri}`; + } + + return `/page/${p._id}`; +} + +/** + * At most 3 segments after "Documentation": first ancestor, optional middle ellipsis, current page. + * Shallow trees (≤2 segments) show everything without ellipsis. + */ +function collapseToFirstEllipsisCurrent(items: BreadcrumbItem[]): BreadcrumbItem[] { + if (items.length <= 2) { + return items; + } + + return [ + items[0], + { title: '…', href: null, isEllipsis: true }, + items[items.length - 1], + ]; +} + +/** + * Breadcrumb trail: ancestors (linked) + current page (plain text, last). + */ +export async function buildPageBreadcrumbs(page: Page): Promise { + const ancestors = await page.getAncestorChain(); + const items: BreadcrumbItem[] = ancestors.map(a => ({ + title: a.title ?? '', + href: hrefForPage(a), + })); + + items.push({ + title: page.title ?? '', + href: null, + }); + + return collapseToFirstEllipsisCurrent(items); +} diff --git a/src/backend/views/pages/page.twig b/src/backend/views/pages/page.twig index 6d24b99b..ba5e7da8 100644 --- a/src/backend/views/pages/page.twig +++ b/src/backend/views/pages/page.twig @@ -7,17 +7,18 @@ Documentation - {{ svg('arrow-right') }} - {% if page._parent %} - + {% for item in breadcrumbItems %} + {{ svg('arrow-right') }} + {% if item.isEllipsis %} + + {% elseif item.href %} + {{ item.title | striptags }} {% else %} - href="/page/{{ pageParent._id }}" - {% endif %}> - {{ pageParent.title }} - - {% endif %} + {{ item.title | striptags }} + {% endif %} + {% endfor %} +