Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
65 changes: 56 additions & 9 deletions src/backend/controllers/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,21 +87,53 @@ class Pages {
}

/**
* Group all pages by their parents
* If the pageId is passed, it excludes passed page from result pages
* 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 nesting depth (indent in the template).
*
* @param {string} pageId - pageId to exclude from result pages
* @returns {Page[]}
* @param excludePageId - when editing, exclude this page and its descendants (same as groupByParent)
*/
public static async groupByParent(pageId = '' as EntityId): Promise<Page[]> {
public static async getParentSelectOptions(
excludePageId?: EntityId
): Promise<Array<{ page: Page; depth: number }>> {
const { pages, pagesMap } = excludePageId
? await this.groupByParentWithMap(excludePageId)
: await this.groupByParentWithMap('' as EntityId);

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

/**
* Same as {@link groupByParent} but returns the pages map from the same load (no second getPagesMap).
*/
private static async groupByParentWithMap(pageId = '' as EntityId): Promise<{
pages: Page[];
pagesMap: Map<string, Page>;
}> {
const rootPageOrder = await PagesOrder.getRootPageOrder(); // get order of the root pages
const childPageOrder = await PagesOrder.getChildPageOrder(); // get order of the all other pages

/**
* If there is no root and child page order, then it returns an empty array
*/
if (!rootPageOrder || (!rootPageOrder && childPageOrder.length <= 0)) {
return [];
return { pages: [], pagesMap: new Map() };
}

const pagesMap = await this.getPagesMap();
Expand Down Expand Up @@ -140,16 +172,31 @@ class Pages {
* Otherwise just returns result itself
*/
if (pageId) {
return this.removeChildren(result, pageId).reduce((prev, curr) => {
const pages = this.removeChildren(result, pageId).reduce((prev, curr) => {
if (curr instanceof Page) {
prev.push(curr);
}

return prev;
}, Array<Page>());
} else {
return result;

return { pages, pagesMap };
}

return { pages: result, pagesMap };
}

/**
* Group all pages by their parents
* If the pageId is passed, it excludes passed page from result pages
*
* @param {string} pageId - pageId to exclude from result pages
* @returns {Page[]}
*/
public static async groupByParent(pageId = '' as EntityId): Promise<Page[]> {
const { pages } = await this.groupByParentWithMap(pageId);

return 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
14 changes: 7 additions & 7 deletions src/backend/views/pages/form.twig
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,14 @@
{% 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 }}
{%- if entry.depth > 0 -%}
{%- for _ in 1..entry.depth %}&nbsp;&nbsp;{% endfor -%}
{%- endif -%}
{{- _page.title|striptags -}}
</option>
{% endif %}
{% endfor %}
Expand All @@ -45,7 +45,7 @@
<select id="above" name="above">
<option value="0">—</option>
{% for _page in parentsChildrenOrdered %}
<option value="{{ toString(_page._id) }}">{{ _page.title }}</option>
<option value="{{ toString(_page._id) }}">{{ _page.title|striptags }}</option>
{% endfor %}
</select>
</div>
Expand Down
Loading
Loading