Skip to content

Commit 5a92c72

Browse files
authored
Merge pull request #332 from codex-team/fix/nested-breadcrumbs
Fix nested breadcrumbs
2 parents 42bf11c + cc83b8e commit 5a92c72

File tree

8 files changed

+147
-22
lines changed

8 files changed

+147
-22
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "codex.docs",
33
"license": "Apache-2.0",
4-
"version": "2.3.0",
4+
"version": "2.3.1",
55
"type": "module",
66
"bin": {
77
"codex.docs": "dist/backend/app.js"

src/backend/build-static.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import appConfig from './utils/appConfig.js';
1414
import Aliases from './controllers/aliases.js';
1515
import Pages from './controllers/pages.js';
1616
import { downloadFavicon } from './utils/downloadFavicon.js';
17+
import { buildPageBreadcrumbs } from './utils/breadcrumbs.js';
1718

1819
/**
1920
* Build static pages from database
@@ -96,7 +97,7 @@ export default async function buildStatic(): Promise<void> {
9697
if (!pageUri) {
9798
throw new Error('Page uri is not defined');
9899
}
99-
const pageParent = await page.getParent();
100+
const breadcrumbItems = await buildPageBreadcrumbs(page);
100101
const pageId = page._id;
101102

102103
if (!pageId) {
@@ -108,7 +109,7 @@ export default async function buildStatic(): Promise<void> {
108109
const menu = createMenuTree(parentIdOfRootPages, allPages, pagesOrder);
109110
const result = await renderTemplate('./views/pages/page.twig', {
110111
page,
111-
pageParent,
112+
breadcrumbItems,
112113
previousPage,
113114
nextPage,
114115
menu,

src/backend/models/page.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,29 @@ class Page {
137137
return new Page(data);
138138
}
139139

140+
/**
141+
* Ancestors from root to immediate parent (for breadcrumbs). Omits virtual root id "0".
142+
*/
143+
public async getAncestorChain(): Promise<Page[]> {
144+
const chain: Page[] = [];
145+
let parentId = this._parent;
146+
147+
while (parentId && !isEqualIds(parentId, '0' as EntityId)) {
148+
const data = await pagesDb.findOne({ _id: parentId });
149+
150+
if (!data?._id) {
151+
break;
152+
}
153+
154+
const ancestor = new Page(data);
155+
156+
chain.unshift(ancestor);
157+
parentId = ancestor._parent;
158+
}
159+
160+
return chain;
161+
}
162+
140163
/**
141164
* Return child pages models
142165
*

src/backend/routes/aliases.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import Alias from '../models/alias.js';
55
import verifyToken from './middlewares/token.js';
66
import PagesFlatArray from '../models/pagesFlatArray.js';
77
import HttpException from '../exceptions/httpException.js';
8+
import { buildPageBreadcrumbs } from '../utils/breadcrumbs.js';
89

910

1011
const router = express.Router();
@@ -33,14 +34,14 @@ router.get('*', verifyToken, async (req: Request, res: Response) => {
3334
case Alias.types.PAGE: {
3435
const page = await Pages.get(alias.id);
3536

36-
const pageParent = await page.getParent();
37+
const breadcrumbItems = await buildPageBreadcrumbs(page);
3738

3839
const previousPage = await PagesFlatArray.getPageBefore(alias.id);
3940
const nextPage = await PagesFlatArray.getPageAfter(alias.id);
4041

4142
res.render('pages/page', {
4243
page,
43-
pageParent,
44+
breadcrumbItems,
4445
previousPage,
4546
nextPage,
4647
config: req.app.locals.config,

src/backend/routes/pages.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import verifyToken from './middlewares/token.js';
55
import allowEdit from './middlewares/locals.js';
66
import PagesFlatArray from '../models/pagesFlatArray.js';
77
import { toEntityId } from '../database/index.js';
8+
import { buildPageBreadcrumbs } from '../utils/breadcrumbs.js';
89

910
const router = express.Router();
1011

@@ -62,14 +63,14 @@ router.get('/page/:id', verifyToken, async (req: Request, res: Response, next: N
6263
try {
6364
const page = await Pages.get(pageId);
6465

65-
const pageParent = await page.parent;
66+
const breadcrumbItems = await buildPageBreadcrumbs(page);
6667

6768
const previousPage = await PagesFlatArray.getPageBefore(pageId);
6869
const nextPage = await PagesFlatArray.getPageAfter(pageId);
6970

7071
res.render('pages/page', {
7172
page,
72-
pageParent,
73+
breadcrumbItems,
7374
config: req.app.locals.config,
7475
previousPage,
7576
nextPage,

src/backend/utils/breadcrumbs.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import Page from '../models/page.js';
2+
3+
type BreadcrumbItem = {
4+
title: string;
5+
href: string | null;
6+
isEllipsis?: boolean;
7+
};
8+
9+
function hrefForPage(p: Page): string {
10+
if (p.uri) {
11+
return `/${p.uri}`;
12+
}
13+
14+
return `/page/${p._id}`;
15+
}
16+
17+
/**
18+
* At most 3 segments after "Documentation": first ancestor, optional middle ellipsis, current page.
19+
* Shallow trees (≤2 segments) show everything without ellipsis.
20+
*/
21+
function collapseToFirstEllipsisCurrent(items: BreadcrumbItem[]): BreadcrumbItem[] {
22+
if (items.length <= 2) {
23+
return items;
24+
}
25+
26+
return [
27+
items[0],
28+
{ title: '…', href: null, isEllipsis: true },
29+
items[items.length - 1],
30+
];
31+
}
32+
33+
/**
34+
* Breadcrumb trail: ancestors (linked) + current page (plain text, last).
35+
*/
36+
export async function buildPageBreadcrumbs(page: Page): Promise<BreadcrumbItem[]> {
37+
const ancestors = await page.getAncestorChain();
38+
const items: BreadcrumbItem[] = ancestors.map(a => ({
39+
title: a.title ?? '',
40+
href: hrefForPage(a),
41+
}));
42+
43+
items.push({
44+
title: page.title ?? '',
45+
href: null,
46+
});
47+
48+
return collapseToFirstEllipsisCurrent(items);
49+
}

src/backend/views/pages/page.twig

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,18 @@
77
<a href="/" class="page__header-nav-item">
88
Documentation
99
</a>
10-
{{ svg('arrow-right') }}
11-
{% if page._parent %}
12-
<a class="page__header-nav-item"
13-
{% if pageParent.uri %}
14-
href="/{{ pageParent.uri }}"
10+
<div class="page__header-nav-crumbs">
11+
{% for item in breadcrumbItems %}
12+
{{ svg('arrow-right') }}
13+
{% if item.isEllipsis %}
14+
<span class="page__header-nav-item page__header-nav-item--ellipsis" aria-hidden="true">{{ item.title }}</span>
15+
{% elseif item.href %}
16+
<a class="page__header-nav-item" href="{{ item.href }}">{{ item.title | striptags }}</a>
1517
{% else %}
16-
href="/page/{{ pageParent._id }}"
17-
{% endif %}>
18-
{{ pageParent.title }}
19-
</a>
20-
{% endif %}
18+
<span class="page__header-nav-item page__header-nav-item--current">{{ item.title | striptags }}</span>
19+
{% endif %}
20+
{% endfor %}
21+
</div>
2122
</div>
2223
<time class="page__header-time">
2324
Last edit {{ (page.body.time / 1000) | date("M d Y") }}

src/frontend/styles/components/page.pcss

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
font-size: 14px;
77
color: var(--color-text-second);
88
line-height: 1.5em;
9+
min-width: 0;
910

1011
@media (--mobile) {
1112
font-size: 13px;
@@ -14,6 +15,42 @@
1415
&-nav {
1516
display: flex;
1617
align-items: center;
18+
min-width: 0;
19+
flex: 1 1 auto;
20+
21+
> .page__header-nav-item:first-of-type {
22+
flex-shrink: 0;
23+
max-width: min(10rem, 28vw);
24+
overflow: hidden;
25+
text-overflow: ellipsis;
26+
white-space: nowrap;
27+
}
28+
29+
&-crumbs {
30+
display: flex;
31+
align-items: center;
32+
min-width: 0;
33+
flex: 1 1 auto;
34+
overflow: hidden;
35+
36+
@media (--mobile) {
37+
display: none;
38+
}
39+
40+
> .page__header-nav-item:not(.page__header-nav-item--current):not(.page__header-nav-item--ellipsis) {
41+
flex: 0 1 auto;
42+
max-width: 38%;
43+
min-width: 0;
44+
overflow: hidden;
45+
text-overflow: ellipsis;
46+
white-space: nowrap;
47+
}
48+
49+
svg {
50+
flex-shrink: 0;
51+
margin: 0 6px;
52+
}
53+
}
1754

1855
&-item {
1956
color: inherit;
@@ -26,19 +63,30 @@
2663
&:hover {
2764
color: var(--color-link-active);
2865
}
29-
}
3066

31-
svg {
32-
margin: 0 6px;
67+
&--current {
68+
flex: 1 1 0;
69+
min-width: 0;
70+
overflow: hidden;
71+
text-overflow: ellipsis;
72+
white-space: nowrap;
73+
font-weight: 600;
74+
color: var(--color-text-main);
75+
pointer-events: none;
76+
}
3377

34-
@media (--mobile) {
35-
display: none;
78+
&--ellipsis {
79+
flex-shrink: 0;
80+
pointer-events: none;
81+
user-select: none;
3682
}
3783
}
3884
}
3985

4086
&-time {
4187
margin-left: auto;
88+
flex-shrink: 0;
89+
white-space: nowrap;
4290

4391
@media (--mobile) {
4492
margin-left: 0;
@@ -47,6 +95,7 @@
4795

4896
&-button {
4997
margin-left: 20px;
98+
flex-shrink: 0;
5099
text-decoration: none;
51100

52101
@media (--mobile) {

0 commit comments

Comments
 (0)