@raven-js/glean
Version:
Glean documentation gold from your codebase - JSDoc parsing, validation, and beautiful doc generation
626 lines (579 loc) âĸ 17.6 kB
JavaScript
/**
* @author Anonyfox <max@anonyfox.com>
* @license MIT
* @see {@link https://github.com/Anonyfox/ravenjs}
* @see {@link https://ravenjs.dev}
* @see {@link https://anonyfox.com}
*/
/**
* Entity page template for /modules/{moduleName}/{entityName}/ route.
*
* Renders individual entity documentation with comprehensive JSDoc processing,
* code examples, cross-references, and navigation context using Bootstrap 5.
*/
import { html, markdownToHTML, safeHtml } from "@raven-js/beak";
import { createEntityAttribution } from "../../extract/models/attribution.js";
import {
attributionBar,
codeBlock,
contentSection,
deprecationAlert,
entityCard,
pageHeader,
seeAlsoLinks,
tableSection,
} from "../components/index.js";
import { baseTemplate } from "./base.js";
/**
* Generate entity documentation HTML page with comprehensive JSDoc sections.
*
* @param {any} data - Entity page data from extractor with comprehensive structure
* @returns {string} Complete HTML page
*
* @example
* // Basic function entity page
* entityPageTemplate({
* entity: { name: 'myFunction', type: 'function', description: '...' },
* documentation: { parameters: [...], returns: {...}, examples: [...] },
* packageName: 'my-package',
* moduleName: 'utils'
* });
*/
export function entityPageTemplate(data, options = {}) {
const { urlBuilder } = /** @type {any} */ (options);
const {
entity,
documentation,
relatedEntities,
packageName,
moduleName,
hasParameters,
hasProperties,
hasMethods,
hasReturns,
hasExamples,
hasRelatedEntities,
hasTypeInfo,
isDeprecated,
} = data;
// Type casts for object property access
/** @type {any} */
const ent = entity;
/** @type {any} */
const docs = documentation;
// Create attribution context for entity (defensive)
let attributionContext = null;
try {
const entityInstance = /** @type {any} */ (data).entityInstance;
if (
entityInstance &&
typeof (/** @type {any} */ (entityInstance).getJSDocTagsByType) ===
"function"
) {
attributionContext = createEntityAttribution(
/** @type {any} */ (entityInstance),
/** @type {any} */ (data).packageMetadata,
/** @type {any} */ (data).allModules, // Pass all modules for re-export tracing
);
}
} catch (_error) {
// Silently fail if attribution creation fails (e.g., in tests with mock entities)
attributionContext = null;
}
// Generate entity type badge color
const getTypeBadgeClass = (/** @type {string} */ type) => {
/** @type {Record<string, string>} */
const typeColors = {
function: "bg-primary",
class: "bg-success",
typedef: "bg-info",
interface: "bg-warning text-dark",
enum: "bg-secondary",
constant: "bg-dark",
variable: "bg-light text-dark",
};
return typeColors[type] || "bg-secondary";
};
// Helper function to link type names to entity pages if they exist
const linkTypeIfEntity = (
/** @type {string} */ typeString,
/** @type {string} */ colorClass = "text-secondary",
) => {
if (!typeString) return html`<span class="text-muted">any</span>`;
// Check all available entities (same module, references, and referenced by)
/** @type {{sameModule?: any[], similar?: any[], references?: any[], referencedBy?: any[]}} */
const typedRelatedEntities = relatedEntities;
const allEntities = [
...(typedRelatedEntities.sameModule || []),
...(typedRelatedEntities.references || []),
...(typedRelatedEntities.referencedBy || []),
];
// If the type matches the current entity, link to current page
if (typeString === entity.name) {
const entityUrl =
entity.importPath && urlBuilder
? /** @type {any} */ (urlBuilder).entityUrl(
entity.importPath.split("/").pop(),
entity.name,
)
: entity.importPath
? `/modules/${entity.importPath.split("/").pop()}/${entity.name}/`
: "#";
return html`<a href="${entityUrl}" class="text-decoration-none"><code class="${colorClass}">${safeHtml`${typeString}`}</code></a>`;
}
/** @type {any} */
const matchingEntity = allEntities.find(
/** @param {any} ent */ (ent) => ent.name === typeString,
);
if (matchingEntity) {
return html`<a href="${safeHtml`${matchingEntity.link}`}" class="text-decoration-none"><code class="${colorClass}">${safeHtml`${typeString}`}</code></a>`;
}
// No matching entity, return as plain text
return html`<code class="${colorClass}">${safeHtml`${typeString}`}</code>`;
};
// Generate breadcrumbs
const breadcrumbs = [
{
href: urlBuilder ? /** @type {any} */ (urlBuilder).homeUrl() : "/",
text: `đĻ ${packageName}`,
},
{
href: urlBuilder
? /** @type {any} */ (urlBuilder).modulesUrl()
: "/modules/",
text: "Modules",
},
{
href: urlBuilder
? /** @type {any} */ (urlBuilder).moduleUrl(moduleName)
: `/modules/${moduleName}/`,
text: moduleName,
},
{ text: entity.name, active: true },
];
// Generate header badges
const badges = [
{ text: ent.type, variant: getTypeBadgeClass(ent.type).replace("bg-", "") },
];
if (ent.isDefault) {
badges.push({ text: "default module", variant: "primary" });
}
// Generate metadata description (removed file location to save space)
const metadataItems = [];
if (docs.since) {
metadataItems.push(`đ
Since ${docs.since}`);
}
if (docs.author.length > 0) {
metadataItems.push(`đ¤ ${docs.author.join(", ")}`);
}
// Generate main content
const content = html`
${pageHeader({
title: ent.name,
subtitle: ent.description,
breadcrumbs,
badges,
description:
metadataItems.length > 0 ? metadataItems.join(" âĸ ") : undefined,
})}
${
isDeprecated
? deprecationAlert({
reason: docs.deprecated.reason,
since: docs.deprecated.since,
})
: ""
}
<!-- Import Statement -->
<div class="card border-primary mb-4">
<div class="card-header bg-primary text-white">
<h5 class="mb-0">đĻ Import</h5>
</div>
<div class="card-body">
<div class="input-group">
<input type="text" class="form-control font-monospace" value="${safeHtml`${ent.importStatement}`}" readonly id="import-${ent.name}">
<button class="btn btn-outline-primary" type="button" onclick="copyImportStatement('import-${ent.name}')" title="Copy import statement">
đ Copy
</button>
</div>
</div>
</div>
<!-- Main Documentation -->
<div class="mb-4">
<!-- TypeScript Signature -->
${
hasTypeInfo && docs.typeInfo.signature
? contentSection({
title: "Type Signature",
icon: "đ",
content: codeBlock({
code: docs.typeInfo.signature,
language: "typescript",
showCopy: false,
}),
})
: ""
}
<!-- Parameters -->
${
hasParameters
? contentSection({
title: "Parameters",
icon: "âī¸",
noPadding: true,
content: tableSection({
headers: ["Name", "Type", "Description", "Default"],
rows: docs.parameters.map(
/** @param {any} param */ (param) => [
html`<code class="text-primary">${safeHtml`${param.name}`}</code>${param.isOptional ? html`<span class="text-muted">?</span>` : ""}`,
linkTypeIfEntity(param.type),
param.description
? markdownToHTML(param.description)
: html`<em class="text-muted">No description</em>`,
param.defaultValue
? html`<code class="text-success">${safeHtml`${param.defaultValue}`}</code>`
: param.isOptional
? html`<span class="text-muted">undefined</span>`
: html`<span class="text-muted">required</span>`,
],
),
}),
})
: ""
}
<!-- Properties -->
${
hasProperties
? contentSection({
title: "Properties",
icon: "đ",
noPadding: true,
content: tableSection({
headers: ["Property", "Type", "Modifiers"],
rows: docs.properties
.sort(
/** @param {{name: string}} a @param {{name: string}} b */ (
a,
b,
) => a.name.localeCompare(b.name),
)
.map(
/** @param {any} property */ (property) => [
// Property name and description
html`<div class="mb-1">
<code class="text-primary">${safeHtml`${property.name}`}</code>${property.isOptional ? html`<span class="text-muted">?</span>` : ""}
</div>
${
property.description
? html`<div class="small text-muted mt-1">${markdownToHTML(property.description)}</div>`
: html`<div class="small text-muted fst-italic mt-1">No description</div>`
}`,
// Type column
linkTypeIfEntity(property.type),
// Handle both typedef properties (required/optional) and class properties (static/instance/private)
html`<div class="d-flex gap-1 flex-wrap">
${
property.isOptional
? html`<span class="badge bg-secondary">optional</span>`
: Object.hasOwn(property, "isOptional")
? html`<span class="badge bg-primary">required</span>`
: ""
}
${
property.isStatic
? html`<span class="badge bg-info">static</span>`
: ""
}
${
property.isPrivate
? html`<span class="badge bg-dark">private</span>`
: ""
}
${
property.isInstance
? html`<span class="badge bg-success">instance</span>`
: ""
}
${
property.defaultValue &&
property.defaultValue !== null
? html`<span class="badge bg-warning text-dark">has default</span>`
: ""
}
</div>`,
],
),
}),
})
: ""
}
<!-- Methods -->
${
hasMethods
? contentSection({
title: "Methods",
icon: "đ§",
noPadding: true,
content: tableSection({
headers: ["Method", "Parameters", "Returns", "Modifiers"],
rows: docs.methods
.sort(
/** @param {{name: string}} a @param {{name: string}} b */ (
a,
b,
) => a.name.localeCompare(b.name),
)
.map(
/** @param {any} method */ (method) => [
// Method name and description
html`<div class="mb-1">
<code class="text-primary">${safeHtml`${method.name}`}</code>
<span class="badge bg-${method.type === "constructor" ? "success" : "primary"} ms-2">${safeHtml`${method.type}`}</span>
${method.isDeprecated ? html`<span class="badge bg-warning ms-1">deprecated</span>` : ""}
</div>
${
method.description
? html`<div class="small text-muted mt-1">${markdownToHTML(method.description)}</div>`
: html`<div class="small text-muted fst-italic mt-1">No description</div>`
}`,
// Parameters column - each on its own line
method.parameters && method.parameters.length > 0
? html`<div class="small">
${method.parameters
.map(
/** @param {any} param @param {number} index */ (
param,
index,
) => html`
<div class="mb-2">
${index > 0 ? html`<hr class="my-2"/>` : ""}
<code class="text-info">${safeHtml`${param.name}`}${param.isOptional ? "?" : ""}</code>: ${linkTypeIfEntity(param.type, "text-secondary")}
${param.description ? html`<br><span class="text-muted">${safeHtml`${param.description}`}</span>` : ""}
</div>
`,
)
.join("")}
</div>`
: html`<span class="text-muted fst-italic">None</span>`,
// Returns column
linkTypeIfEntity(method.returnType),
// Method modifiers badges
html`<div class="d-flex gap-1 flex-wrap">
${
method.isStatic
? html`<span class="badge bg-info">static</span>`
: ""
}
${
method.isPrivate
? html`<span class="badge bg-dark">private</span>`
: ""
}
${
method.isAsync
? html`<span class="badge bg-warning text-dark">async</span>`
: ""
}
${
method.isGenerator
? html`<span class="badge bg-secondary">generator</span>`
: ""
}
</div>`,
],
),
}),
})
: ""
}
<!-- Returns -->
${
hasReturns
? contentSection({
title: "Returns",
icon: "âŠī¸",
content: html`
${
docs.returns.type
? html`
<div class="mb-2">
<strong>Type:</strong>
${linkTypeIfEntity(docs.returns.type)}
</div>
`
: ""
}
${
docs.returns.description
? html`
<div class="mb-0">${markdownToHTML(docs.returns.description)}</div>
`
: ""
}
`,
})
: ""
}
<!-- Exceptions/Throws -->
${
docs.throws.length > 0
? contentSection({
title: "Exceptions",
icon: "đ¨",
noPadding: true,
content: tableSection({
headers: ["Type", "Description"],
rows: docs.throws.map(
/** @param {any} throwsInfo */ (throwsInfo) => [
linkTypeIfEntity(throwsInfo.type, "text-danger"),
throwsInfo.description
? markdownToHTML(throwsInfo.description)
: html`<em class="text-muted">No description</em>`,
],
),
}),
})
: ""
}
<!-- Examples -->
${
hasExamples
? contentSection({
title: "Examples",
icon: "đĄ",
noPadding: true,
content: html`
${docs.examples.map(
/** @param {any} example */ (example) => html`
<div class="border-bottom p-4">
${codeBlock({
code: example.code,
language: example.language,
title: example.title,
})}
</div>
`,
)}
`,
})
: ""
}
<!-- Attribution -->
${
attributionContext?.hasAttribution
? html`
<div class="card border-info mb-4">
<div class="card-body py-2">
${attributionBar(attributionContext)}
${seeAlsoLinks(attributionContext)}
</div>
</div>
`
: ""
}
<!-- Related Entities -->
${
hasRelatedEntities
? contentSection({
title: "Related APIs",
icon: "đ",
content: html`
${
relatedEntities.sameModule.length > 0
? html`
<div class="mb-4">
<h6 class="fw-bold mb-3">Same Module (${moduleName})</h6>
<div class="row g-3">
${relatedEntities.sameModule.map(
/** @param {any} related */ (related) => html`
<div class="col-md-6">
${entityCard({
name: related.name,
type: related.type,
description: related.description,
link: related.link,
showFooter: false,
})}
</div>
`,
)}
</div>
</div>
`
: ""
}
${
relatedEntities.similar.length > 0
? html`
<div class="mb-0">
<h6 class="fw-bold mb-3">Similar APIs (${ent.type})</h6>
<div class="row g-3">
${relatedEntities.similar.map(
/** @param {any} similar */ (similar) => html`
<div class="col-md-6">
${entityCard({
name: similar.name,
type: ent.type,
description: similar.description,
link: similar.link,
badges: [
{ text: similar.moduleName, variant: "secondary" },
],
showFooter: false,
})}
</div>
`,
)}
</div>
</div>
`
: ""
}
`,
})
: ""
}
</div>
`;
// Generate sidebar navigation (matching module-overview.js exactly)
const sidebar =
/** @type {any} */ (data).navigation.allModules.length > 1
? html`
<h6 class="fw-bold mb-3">Module Navigation</h6>
<ul class="nav nav-pills flex-column">
${
/** @type {any} */ (data).navigation.allModules.map(
/** @param {any} navModule */
(navModule) => html`
<li class="nav-item">
<a
href="${navModule.link}"
class="nav-link ${navModule.isCurrent ? "active" : ""}"
>
${navModule.isDefault ? "đĻ " : "đ "}${navModule.name}
</a>
</li>
`,
)
}
</ul>
`
: "";
// Return complete HTML page using base template
return baseTemplate({
title: `${ent.name} - ${moduleName}`,
description: `API documentation for ${ent.name} ${ent.type} in ${packageName}/${moduleName}${ent.description ? ` - ${ent.description}` : ""}`,
packageName,
content,
navigation: {
current: "modules",
sidebar,
},
seo: {
url: "", // Will be filled by route handler
},
packageMetadata: /** @type {any} */ (data).packageMetadata,
generationTimestamp: /** @type {any} */ (data).generationTimestamp,
urlBuilder,
});
}