Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@
"metadatas",
"Modernizr",
"myapp",
"coveoua",
"mycoveocloudorganizationg",
"mycoveoorganization",
"mycoveoorganizationg",
Expand Down
15 changes: 15 additions & 0 deletions packages/documentation/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# @coveo/documentation

This is a typedoc plugin. It ensure that the documentation generated adheres to our styling guidelines.

It is used by
- `@coveo/headless`

## Navigation
This plugin also deviates from the standard typedoc navigation patterns. It allows for top level documents that are ungrouped and it removes the standard
`Documents` tab for ungrouped documents. It also allows for groups to have a mixed of documents and folders based on Category, as opposed to forcing
each document in a group that has categories to _be_ categorized or otherwise lumped together into an `Others` folder.

The sorting of the navigation be specified, both for the top level and optionally by for groups.

NOTE: when specifying the sorting for groups, the key must be in lowercase not the actual casing of the document title.
4 changes: 2 additions & 2 deletions packages/documentation/lib/formatTypeDocToolbar.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export function formatTypeDocToolbar() {
export const formatTypeDocToolbar = () => {
document.addEventListener('DOMContentLoaded', () => {
const header = document.getElementsByTagName('header')[0] as HTMLElement;
if (header) {
Expand All @@ -12,4 +12,4 @@ export function formatTypeDocToolbar() {
typedocThemeSelector.style.display = 'none';
}
});
}
};
78 changes: 78 additions & 0 deletions packages/documentation/lib/hoist.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import {normalize} from './normalize.js';
import type {TNavNode} from './types.js';

/**
* Hoists any child whose title equals `fallbackCategory` so its children are promoted
* to the *parent* level, at the exact position where the bucket appeared.
* Runs depth-first so descendants are processed as well.
*/
export const hoistOtherCategoryInNav = (
root: TNavNode,
fallbackCategory: string
) => {
if (!root) return;

const stack: TNavNode[] = [root];
while (stack.length) {
const node = stack.pop()!;
const kids = node.children;
if (!Array.isArray(kids) || kids.length === 0) continue;

const nextChildren: TNavNode[] = [];
for (const child of kids) {
const title = typeof child.text === 'string' ? child.text : undefined;
if (title && normalize(title) === normalize(fallbackCategory)) {
if (Array.isArray(child.children) && child.children.length) {
// Promote grandchildren to the parent's level at this position
nextChildren.push(...child.children);
}
// Drop the bucket itself
} else {
nextChildren.push(child);
}
}
node.children = nextChildren;

// Recurse
for (const c of node.children) stack.push(c);
}
};

/**
* Top-level helper for themes that return navigation as an array of nodes.
* If an element named like the fallback group exists at the root, its children are
* spliced into the root array at the same index (i.e., promoted to the top level),
* and the bucket is removed. Also applies recursive hoisting within all nodes.
*/
export const hoistOtherCategoryInArray = (
rootItems: TNavNode[],
fallbackCategory: string,
topLevelGroup: string
) => {
if (!Array.isArray(rootItems) || rootItems.length === 0) return;

// First pass: recursively hoist 'Other' within each item
for (const item of rootItems) {
hoistOtherCategoryInNav(item, fallbackCategory);
}

// Second pass: hoist any top-level bucket matching either fallbackCategory ('Other')
// or the requested top-level group (e.g., 'Documents').
let i = 0;
while (i < rootItems.length) {
const item = rootItems[i];
const title = typeof item.text === 'string' ? item.text : undefined;
if (
title &&
(normalize(title) === normalize(fallbackCategory) ||
normalize(title) === normalize(topLevelGroup))
) {
const replacement = Array.isArray(item.children) ? item.children : [];
// Replace the bucket node with its children (promote to top level)
rootItems.splice(i, 1, ...replacement);
// Continue at same index to handle multiple merges
continue;
}
i++;
}
};
181 changes: 165 additions & 16 deletions packages/documentation/lib/index.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,168 @@
import {cpSync} from 'node:fs';
import {dirname, resolve} from 'node:path';
import {fileURLToPath} from 'node:url';
// following docs https://typedoc.org/guides/development/#plugins
// eslint-disable-next-line n/no-unpublished-import
import {type Application, Converter, JSX, RendererEvent} from 'typedoc';
import {
type Application,
Converter,
type DefaultTheme,
type DocumentReflection,
JSX,
KindRouter,
type Models,
type NavigationElement,
ParameterType,
type ProjectReflection,
RendererEvent,
} from 'typedoc';
import {formatTypeDocToolbar} from './formatTypeDocToolbar.js';
import {hoistOtherCategoryInArray, hoistOtherCategoryInNav} from './hoist.js';
import {insertAtomicSearchBox} from './insertAtomicSearchBox.js';
import {insertBetaNote} from './insertBetaNote.js';
import {insertCustomComments} from './insertCustomComments.js';
import {insertMetaTags} from './insertMetaTags.js';
import {insertSiteHeaderBar} from './insertSiteHeaderBar.js';
import {applyTopLevelRenameArray} from './renaming.js';
import {
applyNestedOrderingArray,
applyNestedOrderingNode,
applyTopLevelOrderingArray,
applyTopLevelOrderingNode,
} from './sortNodes.js';
import type {TFrontMatter, TNavNode} from './types.js';

class KebabRouter extends KindRouter {
// Optional: keep .html (default) or change if you want
extension = '.html';

protected getIdealBaseName(refl: Models.Reflection): string {
const name = refl.getFullName?.() ?? refl.name ?? '';
if (!(refl as DocumentReflection)?.frontmatter?.slug)
return this.getUrlSafeName(name);
const {slug} = (refl as DocumentReflection).frontmatter as TFrontMatter;

return `documents/${slug}`;
}
}

const __dirname = dirname(fileURLToPath(import.meta.url));

/**
* Called by TypeDoc when loaded as a plugin.
*/
export function load(app: Application) {
export const load = (app: Application) => {
app.options.addDeclaration({
name: 'hoistOther.fallbackCategory',
help: "Name of the fallback category to hoist (defaults to defaultCategory or 'Other').",
type: ParameterType.String,
});

app.options.addDeclaration({
name: 'hoistOther.topLevelGroup',
help: "Name of the top-level group whose children should be promoted to root (default 'Documents').",
type: ParameterType.String,
});

app.options.addDeclaration({
name: 'hoistOther.topLevelOrder',
help: 'An array to sort the top level nav by.',
type: ParameterType.Array,
});

app.options.addDeclaration({
name: 'hoistOther.nestedOrder',
help: "Object mapping parent title -> ordering array for its children. Use '*' for a default. If omitted, children are sorted alphabetically.",
type: ParameterType.Mixed,
});

app.options.addDeclaration({
name: 'hoistOther.renameModulesTo',
help: "If set, rename any top-level group titled 'Modules' to this string.",
type: ParameterType.String,
});

const originalMethodName = 'getNavigation';
let originalMethod: (
project: ProjectReflection
) => NavigationElement[] | null = null;
app.renderer.on('beginRender', () => {
const theme = app.renderer.theme as DefaultTheme | undefined;
if (!theme) return;

originalMethod = theme.getNavigation;

if (!originalMethod) return;

const opts = app.options;
const fallback =
(opts.getValue('hoistOther.fallbackCategory') as string) ||
(opts.getValue('defaultCategory') as string) ||
'Other';

const topLevelGroup =
(opts.getValue('hoistOther.topLevelGroup') as string) || 'Documents';

const topLevelOrder =
(opts.getValue('hoistOther.topLevelOrder') as string[] | undefined) ||
undefined;

let nestedOrder = opts.getValue('hoistOther.nestedOrder') as
| Record<string, string[]>
| string
| undefined;
if (typeof nestedOrder === 'string') {
try {
nestedOrder = JSON.parse(nestedOrder);
} catch {}
}

const renameModulesTo =
(opts.getValue('hoistOther.renameModulesTo') as string | undefined) ||
undefined;

const typedNestedOrder = nestedOrder as Record<string, string[]>;

theme.getNavigation = function wrappedNavigation(
this: unknown,
...args: unknown[]
) {
const nav = originalMethod!.apply(this, args);

// The nav shape can be an array of nodes or a single root with children
if (Array.isArray(nav)) {
if (renameModulesTo?.trim()) {
applyTopLevelRenameArray(nav, 'Modules', renameModulesTo.trim());
}

hoistOtherCategoryInArray(nav as TNavNode[], fallback, topLevelGroup);

if (topLevelOrder?.length) {
applyTopLevelOrderingArray(nav as TNavNode[], topLevelOrder);
}

applyNestedOrderingArray(nav as TNavNode[], typedNestedOrder);
} else if (nav && typeof nav === 'object') {
if (renameModulesTo?.trim() && Array.isArray(nav.children)) {
applyTopLevelRenameArray(
nav.children,
'Modules',
renameModulesTo.trim()
);
}

hoistOtherCategoryInNav(nav as TNavNode, fallback);
if (
(nav as TNavNode).children &&
topLevelOrder &&
topLevelOrder.length
) {
applyTopLevelOrderingNode(nav as TNavNode, topLevelOrder);
}
applyNestedOrderingNode(nav as TNavNode, typedNestedOrder);
}
return nav;
};
});

// Need the Meta Tags to be inserted first, or it causes issues with the navigation sidebar
app.renderer.hooks.on('head.begin', () => (
<>
Expand Down Expand Up @@ -119,14 +265,8 @@ export function load(app: Application) {
</>
));

const baseAssetsPath = '../../documentation/assets';

const createFileCopyEntry = (sourcePath: string) => ({
from: resolve(__dirname, `${baseAssetsPath}/${sourcePath}`),
to: resolve(app.options.getValue('out'), `assets/${sourcePath}`),
});

const onRenderEnd = () => {
app.renderer.on(RendererEvent.END, () => {
const baseAssetsPath = '../../documentation/assets';
const filesToCopy = [
'css/docs-style.css',
'css/main-new.css',
Expand All @@ -145,19 +285,28 @@ export function load(app: Application) {
];

filesToCopy.forEach((filePath) => {
const file = createFileCopyEntry(filePath);
const file = {
from: resolve(__dirname, `${baseAssetsPath}/${filePath}`),
to: resolve(app.options.getValue('out'), `assets/${filePath}`),
};
cpSync(file.from, file.to);
});

const darkModeJs = {
from: resolve(__dirname, '../../documentation/dist/dark-mode.js'),
to: resolve(app.options.getValue('out'), 'assets/vars/dark-mode.js'),
};
// Restore original to avoid side effects
const theme = app.renderer.theme as DefaultTheme | undefined;
if (theme && originalMethodName && originalMethod) {
theme[originalMethodName] = originalMethod;
}
originalMethod = null;

cpSync(darkModeJs.from, darkModeJs.to);
};
});

app.renderer.on(RendererEvent.END, onRenderEnd);
app.renderer.defineRouter('kebab', KebabRouter);

app.converter.on(Converter.EVENT_CREATE_DECLARATION, insertCustomComments);
}
};
4 changes: 2 additions & 2 deletions packages/documentation/lib/insertAtomicSearchBox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ declare global {
}
}

export function insertAtomicSearchBox() {
export const insertAtomicSearchBox = () => {
const areFunctionalCookiesEnabled = (): boolean => {
return document.cookie
.split('; ')
Expand Down Expand Up @@ -54,4 +54,4 @@ export function insertAtomicSearchBox() {
})();
}
});
}
};
4 changes: 2 additions & 2 deletions packages/documentation/lib/insertBetaNote.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export function insertBetaNote() {
export const insertBetaNote = () => {
document.addEventListener('DOMContentLoaded', () => {
const breadcrumbs = document.querySelector('ul.tsd-breadcrumb');
if (breadcrumbs) {
Expand All @@ -14,4 +14,4 @@ export function insertBetaNote() {
}
}
});
}
};
4 changes: 2 additions & 2 deletions packages/documentation/lib/insertCoveoLogo.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export function insertCoveoLogo(imagePath: string) {
export const insertCoveoLogo = (imagePath: string) => {
document.addEventListener('DOMContentLoaded', () => {
const toolbarContents = document.getElementsByClassName(
'tsd-toolbar-contents'
Expand All @@ -24,4 +24,4 @@ export function insertCoveoLogo(imagePath: string) {
faviconLink.rel = 'icon';
faviconLink.href = `${imagePath}/favicon.ico`;
document.head.appendChild(faviconLink);
}
};
1 change: 1 addition & 0 deletions packages/documentation/lib/insertCustomComments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const comments = [
},
];

// NOTE: cannot be converted into an arrow function `this`
export function insertCustomComments(
this: undefined,
_ctx: Context,
Expand Down
4 changes: 2 additions & 2 deletions packages/documentation/lib/insertMetaTags.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export function insertMetaTags() {
export const insertMetaTags = () => {
const head = document.getElementsByTagName('head')[0];
if (head) {
head.innerHTML += `
Expand All @@ -8,4 +8,4 @@ export function insertMetaTags() {
<meta name="docsSiteBaseUrl" content="/en">
`;
}
}
};
Loading
Loading