Skip to content
Draft
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
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 ensures 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 `documents/${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