Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "codex.docs",
"license": "Apache-2.0",
"version": "2.2.4",
"version": "2.3.0",
"type": "module",
"bin": {
"codex.docs": "dist/backend/app.js"
Expand Down
3 changes: 1 addition & 2 deletions src/backend/build-static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,7 @@ export default async function buildStatic(): Promise<void> {
const parentIdOfRootPages = '0' as EntityId;
const previousPage = await PagesFlatArray.getPageBefore(pageId);
const nextPage = await PagesFlatArray.getPageAfter(pageId);
const menu = createMenuTree(parentIdOfRootPages, allPages, pagesOrder, 2);

const menu = createMenuTree(parentIdOfRootPages, allPages, pagesOrder);
const result = await renderTemplate('./views/pages/page.twig', {
page,
pageParent,
Expand Down
36 changes: 36 additions & 0 deletions src/backend/controllers/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,42 @@ class Pages {
return pagesMap;
}

/**
* Depth in parent chain: 0 for root pages, +1 per ancestor below root (for select indent).
*/
private static computePageDepth(page: Page, pagesMap: Map<string, Page>): number {
let depth = 0;
let cur: Page | undefined = page;

while (cur?._parent && !isEqualIds(cur._parent, '0' as EntityId)) {
depth++;
cur = pagesMap.get(cur._parent.toString());
}

return depth;
}

/**
* Ordered pages for the parent `<select>` with visual nesting (indent = depth).
*
* @param excludePageId - when editing, exclude this page and its descendants (same as groupByParent)
*/
public static async getParentSelectOptions(
excludePageId?: EntityId
): Promise<Array<{ page: Page; depth: number; indent: string }>> {
const pages = excludePageId
? await this.groupByParent(excludePageId)
: await this.groupByParent();
const pagesMap = await this.getPagesMap();
const indentUnit = '\u00a0\u00a0';
Comment thread
Dobrunia marked this conversation as resolved.
Outdated
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seem like it is a presentation-specific logic, and should not be stored in a data model. depth is enough. You can add indents in a template then.


return pages.map((page) => {
const depth = Pages.computePageDepth(page, pagesMap);

return { page, depth, indent: indentUnit.repeat(depth) };
});
}

/**
* Group all pages by their parents
* If the pageId is passed, it excludes passed page from result pages
Expand Down
8 changes: 5 additions & 3 deletions src/backend/models/pagesFlatArray.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ class PagesFlatArray {
/**
* Returns pages flat array
*
* @param nestingLimit - number of flat array nesting, set null to dismiss the restriction, default nesting 2
* @param nestingLimit - max `level` to keep (level 0 = root pages, 1 = their children, …).
* Pass **null** to return the full tree order (needed for prev/next links past depth 2).
* @returns {Promise<Array<PagesFlatArrayData>>}
*/
public static async get(nestingLimit: number | null = 2): Promise<Array<PagesFlatArrayData>> {
Expand Down Expand Up @@ -108,7 +109,8 @@ class PagesFlatArray {
* @returns {Promise<PagesFlatArrayData | undefined>}
*/
public static async getPageBefore(pageId: EntityId): Promise<PagesFlatArrayData | undefined> {
const arr = await this.get();
/** `null` = no level cap; default (2) would drop pages at level ≥2 from the chain */
const arr = await this.get(null);

const pageIndex = arr.findIndex((item) => isEqualIds(item.id, pageId));

Expand All @@ -128,7 +130,7 @@ class PagesFlatArray {
* @returns {Promise<PagesFlatArrayData | undefined>}
*/
public static async getPageAfter(pageId: EntityId): Promise<PagesFlatArrayData | undefined> {
const arr = await this.get();
const arr = await this.get(null);

const pageIndex = arr.findIndex( (item) => isEqualIds(item.id, pageId));

Expand Down
2 changes: 1 addition & 1 deletion src/backend/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,6 @@ router.use('/', pagesMiddleware, home);
router.use('/', pagesMiddleware, pages);
router.use('/', pagesMiddleware, auth);
router.use('/api', verifyToken, allowEdit, api);
router.use('/', aliases);
router.use('/', pagesMiddleware, aliases);

export default router;
2 changes: 1 addition & 1 deletion src/backend/routes/middlewares/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export default asyncMiddleware(async (req: Request, res: Response, next: NextFun
const pages = await Pages.getAllPages();
const pagesOrder = await PagesOrder.getAll();

res.locals.menu = createMenuTree(parentIdOfRootPages, pages, pagesOrder, 2);
res.locals.menu = createMenuTree(parentIdOfRootPages, pages, pagesOrder);
} catch (error) {
console.log('Can not load menu:', error);
}
Expand Down
10 changes: 4 additions & 6 deletions src/backend/routes/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,10 @@ const router = express.Router();
*/
router.get('/page/new', verifyToken, allowEdit, async (req: Request, res: Response, next: NextFunction) => {
try {
const pagesAvailableGrouped = await Pages.groupByParent();

console.log(pagesAvailableGrouped);
const parentSelectOptions = await Pages.getParentSelectOptions();

res.render('pages/form', {
pagesAvailableGrouped,
parentSelectOptions,
page: null,
});
} catch (error) {
Expand All @@ -36,7 +34,7 @@ router.get('/page/edit/:id', verifyToken, allowEdit, async (req: Request, res: R
try {
const page = await Pages.get(pageId);
const pagesAvailable = await Pages.getAllExceptChildren(pageId);
const pagesAvailableGrouped = await Pages.groupByParent(pageId);
const parentSelectOptions = await Pages.getParentSelectOptions(pageId);

if (!page._parent) {
throw new Error('Parent not found');
Expand All @@ -47,7 +45,7 @@ router.get('/page/edit/:id', verifyToken, allowEdit, async (req: Request, res: R
res.render('pages/form', {
page,
parentsChildrenOrdered,
pagesAvailableGrouped,
parentSelectOptions,
});
} catch (error) {
res.status(404);
Expand Down
41 changes: 25 additions & 16 deletions src/backend/utils/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,26 @@ import Page from '../models/page.js';
import PageOrder from '../models/pageOrder.js';
import { isEqualIds } from '../database/index.js';

/** Max sidebar nesting depth (root sections count as depth 1). */
const MENU_MAX_DEPTH = 64;

/**
* Process one-level pages list to parent-children list
* Build parent→children menu tree for the sidebar.
*
* @param parentPageId - parent page id
* @param pages - list of all available pages
* @param pagesOrder - list of pages order
* @param level - max level recursion
* @param currentLevel - current level of element
* @param maxDepth - stop recursing deeper than this (default: MENU_MAX_DEPTH)
* currentDepth - current depth from the tree root (1 = direct children of parentPageId)
*/
export function createMenuTree(parentPageId: EntityId, pages: Page[], pagesOrder: PageOrder[], level = 1, currentLevel = 1): Page[] {
const childrenOrder = pagesOrder.find(order => isEqualIds(order.data.page, parentPageId));
export function createMenuTree(
parentPageId: EntityId,
pages: Page[],
pagesOrder: PageOrder[],
maxDepth: number = MENU_MAX_DEPTH,
currentDepth: number = 1
): Page[] {
const childrenOrder = pagesOrder.find((order) => isEqualIds(order.data.page, parentPageId));

/**
* branch is a page children in tree
Expand All @@ -31,19 +40,19 @@ export function createMenuTree(parentPageId: EntityId, pages: Page[], pagesOrder
const unordered = pages.filter(page => isEqualIds(page._parent, parentPageId));
const branch = Array.from(new Set([...ordered, ...unordered]));

/**
* stop recursion when we got the passed max level
*/
if (currentLevel === level + 1) {
return [];
}
const canRecurse = currentDepth < maxDepth;

/**
* Each parents children can have subbranches
*/
return branch.filter(page => page && page._id).map(page => {
return Object.assign({
children: createMenuTree(page._id, pages, pagesOrder, level, currentLevel + 1),
}, page.data);
});
return branch
.filter((page) => page && page._id)
.map((page) => {
const subtree = canRecurse
? createMenuTree(page._id!, pages, pagesOrder, maxDepth, currentDepth + 1)
: [];

/** `children` must win over anything stored on the page document */
return { ...page.data, children: subtree };
});
}
26 changes: 26 additions & 0 deletions src/backend/views/components/sidebar-section.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{% set is_leaf = node.children is not defined or node.children is empty %}
<section class="docs-sidebar__section{{ nested ? ' docs-sidebar__section--nested' : '' }}{{ is_leaf ? ' docs-sidebar__section--leaf' : '' }}" data-id="{{ node._id }}">
<a class="docs-sidebar__section-title-wrapper"
href="{{ node.uri ? '/' ~ node.uri : '/page/' ~ node._id }}"
>
<div class="docs-sidebar__section-title {{ page is defined and toString(page._id) == toString(node._id) ? 'docs-sidebar__section-title--active' : '' }}">
<span>
{{ node.title | striptags }}
</span>
{% if node.children is defined and node.children is not empty %}
<button type="button" class="docs-sidebar__section-toggler" aria-label="Toggle section">
{{ svg('arrow-up') }}
</button>
{% endif %}
</div>
</a>
{% if node.children is defined and node.children is not empty %}
<ul class="docs-sidebar__section-list docs-sidebar__section-list--nested">
{% for child in node.children %}
<li>
{% include 'components/sidebar-section.twig' with { node: child, nested: true } %}
</li>
{% endfor %}
</ul>
{% endif %}
</section>
32 changes: 1 addition & 31 deletions src/backend/views/components/sidebar.twig
Original file line number Diff line number Diff line change
Expand Up @@ -10,37 +10,7 @@
<input class="docs-sidebar__search" type="text" placeholder="Search" />
</span>
{% for firstLevelPage in menu %}
<section class="docs-sidebar__section" data-id="{{firstLevelPage._id}}">
<a class="docs-sidebar__section-title-wrapper"
href="{{firstLevelPage.uri ? '/' ~ firstLevelPage.uri : '/page/' ~ firstLevelPage._id }}"
>
<div class="docs-sidebar__section-title {{page is defined and page._id == firstLevelPage._id ? 'docs-sidebar__section-title--active' : ''}}">
<span>
{{ firstLevelPage.title | striptags }}
</span>
{% if firstLevelPage.children is not empty %}
<button class="docs-sidebar__section-toggler">
{{ svg('arrow-up') }}
</button>
{% endif %}
</div>
</a>
{% if firstLevelPage.children is not empty %}
<ul class="docs-sidebar__section-list">
{% for child in firstLevelPage.children %}
<li>
<a
class="docs-sidebar__section-list-item-wrapper"
href="{{ child.uri ? '/' ~ child.uri : '/page/' ~ child._id }}">
<div class="docs-sidebar__section-list-item {{page is defined and toString(page._id) == toString(child._id) ? 'docs-sidebar__section-list-item--active' : ''}}">
<span>{{ child.title | striptags }}</span>
</div>
</a>
</li>
{% endfor %}
</ul>
{% endif %}
</section>
{% include 'components/sidebar-section.twig' with { node: firstLevelPage, nested: false } %}
{% endfor %}

<div class="docs-sidebar__logo">
Expand Down
9 changes: 3 additions & 6 deletions src/backend/views/pages/form.twig
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,11 @@
{% endif %}
<select id="parent" name="parent">
<option value="0">Root</option>
{% for _page in pagesAvailableGrouped %}
{% for entry in parentSelectOptions %}
{% set _page = entry.page %}
{% if toString(_page._id) != toString(currentPageId) %}
<option value="{{ toString(_page._id) }}" {{ page is not empty and toString(page._parent) == toString(_page._id) ? 'selected' : ''}}>
{% if _page._parent != "0" %}
&nbsp;
&nbsp;
{% endif %}
{{ _page.title }}
{{- entry.indent -}}{{- _page.title -}}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please, add "strip tags" filter to the title to prevent xss

</option>
{% endif %}
{% endfor %}
Expand Down
Loading
Loading