@raven-js/glean
Version:
Glean documentation gold from your codebase - JSDoc parsing, validation, and beautiful doc generation
564 lines (513 loc) ⢠16.4 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}
*/
/**
* Base HTML template with Bootstrap 5, SEO, and responsive design.
*
* Foundation template providing consistent structure for all documentation pages
* with navigation, footer, and optimized developer UX.
*/
import { html } from "@raven-js/beak";
/**
* Generate base HTML template with Bootstrap 5 layout, SEO, and navigation.
*
* @param {Object} options - Template configuration
* @param {string} options.title - Page title
* @param {string} options.description - Meta description
* @param {string} options.packageName - Package name for branding
* @param {string} options.content - Main page content HTML
* @param {Object} [options.seo] - SEO configuration
* @param {string} [options.seo.url] - Canonical URL
* @param {string} [options.seo.image] - Open Graph image
* @param {Object} [options.navigation] - Navigation data
* @param {string} [options.navigation.current] - Current active page
* @param {boolean} [options.navigation.showSearch] - Show search form
* @param {string} [options.navigation.sidebar] - Sidebar content
* @param {Object} [options.packageMetadata] - Package metadata for footer
* @param {string|Object} [options.packageMetadata.author] - Author information
* @param {string} [options.packageMetadata.homepage] - Homepage URL
* @param {Object} [options.packageMetadata.repository] - Repository info
* @param {string} [options.packageMetadata.repository.url] - Repository URL
* @param {Object} [options.packageMetadata.bugs] - Bug tracker info
* @param {string} [options.packageMetadata.bugs.url] - Bug tracker URL
* @param {Object} [options.packageMetadata.funding] - Funding info
* @param {string} [options.packageMetadata.funding.url] - Funding URL
* @param {string} [options.generationTimestamp] - Generation timestamp
* @param {Object} [options.urlBuilder] - URL builder for base path handling
* @returns {string} Complete HTML document
*
* @example
* // Basic page template
* baseTemplate({
* title: 'My Package',
* description: 'Package documentation',
* packageName: 'my-package',
* content: '<h1>Content</h1>'
* });
*/
export function baseTemplate({
title,
description,
packageName,
content,
seo = {},
navigation = {},
packageMetadata = null,
generationTimestamp = null,
urlBuilder = null,
}) {
const fullTitle = title;
const canonicalUrl = seo.url || "";
const ogImage = seo.image || "";
// Process package metadata for footer
let authorName = "";
let authorEmail = "";
if (packageMetadata?.author) {
const author = packageMetadata.author;
if (typeof author === "string") {
// Parse "Name <email>" format
const match = author.match(/^(.+?)\s*<([^>]+)>$/);
if (match) {
authorName = match[1].trim();
authorEmail = match[2].trim();
} else {
authorName = author;
}
} else if (
author &&
typeof author === "object" &&
/** @type {any} */ (author).name
) {
/** @type {any} */
const authorObj = author;
authorName = authorObj.name;
authorEmail = authorObj.email || "";
}
}
// Generate package links
const homepageUrl = packageMetadata?.homepage;
const repositoryUrl =
typeof packageMetadata?.repository === "string"
? packageMetadata.repository
: packageMetadata?.repository?.url || "";
const issuesUrl =
typeof packageMetadata?.bugs === "string"
? packageMetadata.bugs
: packageMetadata?.bugs?.url || "";
const fundingUrl =
typeof packageMetadata?.funding === "string"
? packageMetadata.funding
: packageMetadata?.funding?.url || "";
// Generate timestamp string
const timestamp = generationTimestamp
? new Date(generationTimestamp).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
})
: new Date().toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
return html`
<html lang="en" data-bs-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!-- Core Meta Tags -->
<title>${fullTitle}</title>
<meta name="description" content="${description}">
<meta name="generator" content="Glean Documentation Generator">
<meta name="theme-color" content="#6366f1">
<!-- Open Graph Meta Tags -->
<meta property="og:type" content="website">
<meta property="og:title" content="${title}">
<meta property="og:description" content="${description}">
${canonicalUrl ? html`<meta property="og:url" content="${canonicalUrl}">` : ""}
${ogImage ? html`<meta property="og:image" content="${ogImage}">` : ""}
<meta property="og:site_name" content="${packageName} Documentation">
<!-- Twitter Card Meta Tags -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${title}">
<meta name="twitter:description" content="${description}">
${ogImage ? html`<meta name="twitter:image" content="${ogImage}">` : ""}
<!-- SEO Meta Tags -->
${canonicalUrl ? html`<link rel="canonical" href="${canonicalUrl}">` : ""}
<meta name="robots" content="index, follow">
<meta name="author" content="Generated by Glean">
<!-- Favicon -->
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<!-- Bootstrap 5 CSS -->
<link href="/bootstrap.min.css" rel="stylesheet">
<!-- Custom documentation enhancements -->
<style>
/* CSS variables for theming */
:root {
--glean-primary: #0d6efd;
--glean-secondary: #6c757d;
--glean-background: #ffffff;
--glean-surface: #f8f9fa;
}
/* Copy functionality styling */
.code-copy-btn {
position: absolute;
top: 0.5rem;
right: 0.5rem;
opacity: 0.7;
transition: opacity 0.2s ease;
}
.code-copy-btn:hover {
opacity: 1;
}
/* Search highlighting */
.search-highlight {
background-color: rgba(255, 193, 7, 0.2);
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
}
/* Enhanced styling for documentation */
.navbar-brand {
font-weight: 700;
color: var(--glean-primary) ;
}
.code-block {
background-color: var(--glean-surface);
border: 1px solid #dee2e6;
border-radius: 0.375rem;
padding: 1rem;
overflow-x: auto;
}
/* README content styling */
.readme-content h1, .readme-content h2, .readme-content h3,
.readme-content h4, .readme-content h5, .readme-content h6 {
margin-top: 1.5rem;
margin-bottom: 1rem;
}
.readme-content h1:first-child,
.readme-content h2:first-child,
.readme-content h3:first-child {
margin-top: 0;
}
.readme-content p {
margin-bottom: 1rem;
}
.readme-content pre {
background-color: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 0.375rem;
padding: 1rem;
overflow-x: auto;
}
.readme-content code {
background-color: #f8f9fa;
color: #e83e8c;
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
font-size: 0.875em;
}
.readme-content pre code {
background-color: transparent;
color: inherit;
padding: 0;
}
.readme-content table {
width: 100%;
margin-bottom: 1rem;
border-collapse: collapse;
}
.readme-content th,
.readme-content td {
padding: 0.5rem;
border: 1px solid #dee2e6;
}
.readme-content th {
background-color: #f8f9fa;
font-weight: 600;
}
/* Mobile responsive adjustments */
@media (max-width: 768px) {
.container-fluid {
padding-left: 0.75rem;
padding-right: 0.75rem;
}
.sidebar {
border-end: none ;
border-bottom: 1px solid #dee2e6;
}
}
</style>
</head>
<body>
<!-- Main Navigation -->
<nav class="navbar navbar-expand-lg navbar-light bg-white border-bottom">
<div class="container-fluid">
<a class="navbar-brand fw-bold text-primary" href="${urlBuilder ? /** @type {any} */ (urlBuilder).homeUrl() : "/"}">
${packageName} <small class="text-muted">Documentation</small>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link ${navigation.current === "overview" ? "active" : ""}" href="${urlBuilder ? /** @type {any} */ (urlBuilder).homeUrl() : "/"}">Overview</a>
</li>
<li class="nav-item">
<a class="nav-link ${navigation.current === "modules" ? "active" : ""}" href="${urlBuilder ? /** @type {any} */ (urlBuilder).modulesUrl() : "/modules/"}">Modules</a>
</li>
</ul>
</div>
</div>
</nav>
<!-- Main Content Area -->
<div class="container-fluid">
<div class="row">
<!-- Sidebar Navigation (if provided) -->
${
navigation.sidebar
? html`
<div class="col-md-3 col-lg-2 bg-light border-end sidebar">
<div class="p-3" style="min-height: calc(100vh - 56px);">
${navigation.sidebar}
</div>
</div>
`
: ""
}
<!-- Main Content -->
<div class="${navigation.sidebar ? "col-md-9 col-lg-10" : "col-12"}">
<main class="p-4" style="min-height: calc(100vh - 56px);">
${content}
</main>
</div>
</div>
</div>
<!-- Footer -->
<footer class="bg-light border-top py-5 mt-5">
<div class="container">
<div class="row align-items-start">
<!-- Package Info (Left) -->
${
packageName || packageMetadata
? html`
<div class="col-lg-4 col-md-6 mb-3 mb-lg-0">
<div class="mb-2">
${
packageName
? html`<h6 class="mb-2 fw-semibold text-dark">š¦ ${packageName}</h6>`
: ""
}
<div class="d-flex flex-wrap gap-3">
${
homepageUrl
? html`
<a href="${homepageUrl}" target="_blank" rel="noopener noreferrer"
class="text-decoration-none text-muted small">
š Homepage
</a>
`
: ""
}
${
repositoryUrl
? html`
<a href="${repositoryUrl}" target="_blank" rel="noopener noreferrer"
class="text-decoration-none text-muted small">
š Repository
</a>
`
: ""
}
${
issuesUrl
? html`
<a href="${issuesUrl}" target="_blank" rel="noopener noreferrer"
class="text-decoration-none text-muted small">
š Issues
</a>
`
: ""
}
${
fundingUrl
? html`
<a href="${fundingUrl}" target="_blank" rel="noopener noreferrer"
class="text-decoration-none text-muted small">
š Funding
</a>
`
: ""
}
</div>
</div>
</div>
`
: ""
}
<!-- Author Attribution (Center) -->
${
authorName
? html`
<div class="col-lg-4 col-md-6 mb-3 mb-lg-0 text-lg-center">
<div class="text-muted small">
<span class="fw-medium">by ${authorName}</span>
${
authorEmail
? html`<br><a href="mailto:${authorEmail}" class="text-muted text-decoration-none">${authorEmail}</a>`
: ""
}
</div>
</div>
`
: ""
}
<!-- Glean & Timestamp (Right) -->
<div class="${authorName || packageName || packageMetadata ? "col-lg-4 col-12 text-lg-end" : "col-12 text-center"}">
<div class="text-muted small">
<div class="mb-1">Generated ${timestamp}</div>
<a href="https://github.com/Anonyfox/ravenjs/tree/main/packages/glean"
target="_blank" rel="noopener noreferrer"
class="text-decoration-none text-muted fw-medium">
ā” Powered by Glean
</a>
</div>
</div>
</div>
</div>
</footer>
<!-- Bootstrap 5 JavaScript -->
<script src="/popper.js" type="module"></script>
<script src="/bootstrap.esm.js" type="module"></script>
<!-- Enhanced Documentation Features -->
<script>
// Smooth scrolling for anchor links
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
anchor.addEventListener('click', function (e) {
e.preventDefault();
const target = document.querySelector(this.getAttribute('href'));
if (target) {
target.scrollIntoView({ behavior: 'smooth' });
}
});
});
// Copy code block functionality (only for blocks without existing copy buttons)
document.querySelectorAll('pre').forEach(block => {
if (block.querySelector('code') && !block.parentElement.querySelector('button[onclick*="copyCodeBlock"]')) {
const copyBtn = document.createElement('button');
copyBtn.className = 'btn btn-sm btn-outline-secondary code-copy-btn';
copyBtn.textContent = 'š';
copyBtn.title = 'Copy code';
copyBtn.onclick = () => {
navigator.clipboard.writeText(block.textContent);
copyBtn.textContent = 'ā';
setTimeout(() => copyBtn.textContent = 'š', 2000);
};
block.style.position = 'relative';
block.appendChild(copyBtn);
}
});
// Copy import statement functionality
window.copyImportStatement = function(inputId) {
const input = document.getElementById(inputId);
if (input) {
input.select();
navigator.clipboard.writeText(input.value);
const btn = input.nextElementSibling;
if (btn) {
const originalText = btn.textContent;
btn.textContent = 'ā Copied!';
setTimeout(() => btn.textContent = originalText, 2000);
}
}
};
// Copy code block functionality
window.copyCodeBlock = function(blockId) {
const block = document.getElementById(blockId);
if (block) {
const codeElement = block.querySelector('code');
const text = codeElement ? codeElement.textContent : block.textContent;
navigator.clipboard.writeText(text).then(() => {
const btn = block.parentElement.querySelector('button[onclick*="copyCodeBlock"]');
if (btn) {
const originalText = btn.textContent;
btn.textContent = 'ā Copied!';
setTimeout(() => btn.textContent = originalText, 2000);
}
}).catch(() => {
console.error('Failed to copy code');
});
}
};
// Search highlighting (basic implementation)
const searchParams = new URLSearchParams(window.location.search);
const searchTerm = searchParams.get('search');
if (searchTerm) {
const walker = document.createTreeWalker(
document.querySelector('main'),
NodeFilter.SHOW_TEXT
);
let node;
while (node = walker.nextNode()) {
if (node.textContent.toLowerCase().includes(searchTerm.toLowerCase())) {
const highlightedText = node.textContent.replace(
new RegExp(searchTerm, 'gi'),
'<span class="search-highlight">$&</span>'
);
const wrapper = document.createElement('span');
wrapper.innerHTML = highlightedText;
node.parentNode.replaceChild(wrapper, node);
}
}
}
// Copy to clipboard functionality
function copyToClipboard(text) {
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(() => {
showCopyFeedback();
}).catch(() => {
fallbackCopyTextToClipboard(text);
});
} else {
fallbackCopyTextToClipboard(text);
}
}
function fallbackCopyTextToClipboard(text) {
const textArea = document.createElement("textarea");
textArea.value = text;
textArea.style.top = "0";
textArea.style.left = "0";
textArea.style.position = "fixed";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand('copy');
showCopyFeedback();
} catch (err) {
console.error('Fallback: Could not copy text');
}
document.body.removeChild(textArea);
}
function showCopyFeedback() {
// Create temporary feedback element
const feedback = document.createElement('div');
feedback.textContent = 'Copied!';
feedback.className = 'position-fixed top-50 start-50 translate-middle bg-success text-white px-3 py-2 rounded';
feedback.style.zIndex = '9999';
document.body.appendChild(feedback);
setTimeout(() => {
if (feedback.parentNode) {
feedback.parentNode.removeChild(feedback);
}
}, 2000);
}
</script>
</body>
</html>`;
}