* Add Security docs section, SEO improvements, and doc site enhancements - Add Authorization guide covering per-server and per-tool OAuth patterns - Add CSP & CORS guide extracted from Patterns doc - Group both under a new Security section in the documentation nav - Replace CSP & CORS section in Patterns with cross-reference to new guide - Rename doc site title from package name to 'MCP Apps' - Rename 'Modules' nav section to 'API Documentation' - Ensure Security section appears before API Documentation in TOC - Add SEO plugin with JSON-LD structured data (TechArticle/WebPage) - Add per-page meta descriptions from frontmatter - Add description frontmatter to all documentation pages - Add hostedBaseUrl for sitemap.xml generation and canonical URLs - Normalize document page slugs to lowercase hyphenated format - Update compressed navigation and search index data to match new slugs * Add favicons with light/dark mode support - Download favicons from modelcontextprotocol.io (light + dark variants) - Inject favicon link tags into every page with correct relative paths - Support prefers-color-scheme media queries for light/dark mode - Include apple-touch-icon for iOS - Copy favicon assets to output directory during doc build * Fix HTML sanitization in SEO plugin with proper parser Replace regex-based HTML tag stripping with htmlparser2 for extracting plain-text descriptions from rendered pages. * Add custom theme to align with modelcontextprotocol.io styling - Import Inter and JetBrains Mono fonts (matching MCP site) - Override light mode colors: pure white backgrounds, softer gray borders - Override dark mode colors: match MCP site dark background (#0f1117) - Taller header (3.5rem) with backdrop-blur frosted glass effect - Sidebar: adjusted spacing, font size, and hover/active states - Content: increased line-height (1.6), more heading breathing room - Code blocks: JetBrains Mono, subtle border, more padding - Fix CSS load order: custom.css now loads after theme stylesheets * Align sidebar nav styling with modelcontextprotocol.io - Active item: tinted blue background with blue text (matching MCP's primary-color tinted active state) - Non-active items: muted gray text, subtle hover background - Larger border-radius (0.75rem/rounded-xl) matching MCP sidebar - Group headers: semibold with slight letter spacing - Proper dark mode variants for all states * Fix sidebar active item: dark gray background with bold text * Make sidebar active item more prominent: bolder weight, stronger background * Highlight current page in sidebar nav, normalize code font size Inject a small script via the SEO plugin that marks the active sidebar link with a "current" class (TypeDoc doesn't do this natively for document pages). Uses a MutationObserver since the sidebar is populated asynchronously. Pathname comparison strips .html extensions to support clean-URL servers. Also bumps code/pre font-size from 0.85em to 1em so code snippets match the surrounding body text size. * Split styling into dedicated plugin, fix auth terminology, improve breadcrumbs Extract CSS cascade fix, breadcrumb labels, and sidebar nav highlighting from the SEO plugin into a new typedoc-plugin-mcpstyle.mjs to keep concerns separated. Fix authorization doc: use "authorization" consistently instead of mixing with "authentication", link to MCP spec and RFC 9728 for Protected Resource Metadata, remove redundant spec link paragraph. Rename "Documents" nav section to "Getting Started" and add explicit group frontmatter to all doc pages. Breadcrumbs now show the section name instead of duplicating the page title.
315 lines
9.9 KiB
JavaScript
315 lines
9.9 KiB
JavaScript
/**
|
|
* TypeDoc plugin that adds SEO metadata to generated pages.
|
|
*
|
|
* - Normalizes document page filenames to lowercase-hyphenated slugs
|
|
* - Injects JSON-LD (TechArticle / WebPage) structured data for search crawlers
|
|
* - Adds per-page meta description tags extracted from page content
|
|
* - Copies favicons to the output directory
|
|
*/
|
|
|
|
import { Renderer } from "typedoc";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import zlib from "node:zlib";
|
|
import * as htmlparser2 from "htmlparser2";
|
|
|
|
const SITE_NAME = "MCP Apps";
|
|
|
|
/**
|
|
* Convert a TypeDoc-generated document filename to a lowercase hyphenated slug.
|
|
* e.g. "CSP_and_CORS.html" → "csp-and-cors.html"
|
|
* "Testing_MCP_Apps.html" → "testing-mcp-apps.html"
|
|
* @param {string} filename
|
|
* @returns {string}
|
|
*/
|
|
function toSlug(filename) {
|
|
return filename
|
|
.replace(/\.html$/, "")
|
|
.replace(/_/g, "-")
|
|
.toLowerCase()
|
|
.concat(".html");
|
|
}
|
|
|
|
/**
|
|
* Extract a plain-text description from the rendered HTML body.
|
|
* Parses the first content paragraph using htmlparser2 and truncates.
|
|
* @param {string} html
|
|
* @returns {string}
|
|
*/
|
|
function extractDescription(html) {
|
|
// Find the main content area
|
|
const contentMatch = html.match(
|
|
/<div class="tsd-panel tsd-typography">([\s\S]*?)<\/div>\s*<\/div>/,
|
|
);
|
|
if (!contentMatch) return "";
|
|
|
|
const content = contentMatch[1];
|
|
|
|
// Find first <p> that has real content (skip headings, code blocks, lists)
|
|
const paragraphs = content.match(/<p>([^<][\s\S]*?)<\/p>/g);
|
|
if (!paragraphs) return "";
|
|
|
|
for (const p of paragraphs) {
|
|
const text = htmlparser2
|
|
.parseDocument(p)
|
|
.children.map((node) => htmlparser2.DomUtils.textContent(node))
|
|
.join("")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
|
|
// Skip very short or code-heavy paragraphs
|
|
if (text.length > 40) {
|
|
return text.length > 160 ? text.slice(0, 157) + "..." : text;
|
|
}
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
/**
|
|
* Extract the page title from the <title> tag.
|
|
* @param {string} html
|
|
* @returns {string}
|
|
*/
|
|
function extractTitle(html) {
|
|
const match = html.match(/<title>([^<]+)<\/title>/);
|
|
return match ? match[1] : SITE_NAME;
|
|
}
|
|
|
|
/**
|
|
* Determine if a page is a document (guide) or API reference page.
|
|
* @param {string} url
|
|
* @returns {boolean}
|
|
*/
|
|
function isDocumentPage(url) {
|
|
return url.includes("documents/");
|
|
}
|
|
|
|
/**
|
|
* Build a JSON-LD structured data object for the page.
|
|
* @param {object} options
|
|
* @param {string} options.title
|
|
* @param {string} options.description
|
|
* @param {string} options.url
|
|
* @param {boolean} options.isDocument
|
|
* @returns {object}
|
|
*/
|
|
function buildJsonLd({ title, description, url, isDocument }) {
|
|
return {
|
|
"@context": "https://schema.org",
|
|
"@type": isDocument ? "TechArticle" : "WebPage",
|
|
name: title,
|
|
headline: title,
|
|
...(description && { description }),
|
|
...(url && { url }),
|
|
isPartOf: {
|
|
"@type": "WebSite",
|
|
name: SITE_NAME,
|
|
url: "https://apps.extensions.modelcontextprotocol.io/",
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* TypeDoc plugin entry point.
|
|
* @param {import('typedoc').Application} app
|
|
*/
|
|
export function load(app) {
|
|
const hostedBaseUrl = app.options.getValue("hostedBaseUrl") || "";
|
|
|
|
// --- Per-page: inject JSON-LD, meta descriptions, and favicons ---
|
|
app.renderer.on(Renderer.EVENT_END_PAGE, (page) => {
|
|
if (!page.contents) return;
|
|
|
|
const title = extractTitle(page.contents);
|
|
|
|
// Prefer frontmatter description, fall back to auto-extraction
|
|
const frontmatterDesc =
|
|
page.model?.isDocument?.() && page.model.frontmatter?.description
|
|
? String(page.model.frontmatter.description)
|
|
: "";
|
|
const description = frontmatterDesc || extractDescription(page.contents);
|
|
const fullUrl = hostedBaseUrl
|
|
? `${hostedBaseUrl.replace(/\/$/, "")}/${page.url}`
|
|
: "";
|
|
|
|
// Build JSON-LD
|
|
const jsonLd = buildJsonLd({
|
|
title,
|
|
description,
|
|
url: fullUrl,
|
|
isDocument: isDocumentPage(page.url),
|
|
});
|
|
|
|
const jsonLdScript = `<script type="application/ld+json">${JSON.stringify(jsonLd)}</script>`;
|
|
|
|
// Build meta description tag (replace the generic one if present)
|
|
const metaDescription = description
|
|
? `<meta name="description" content="${description.replace(/"/g, """)}"/>`
|
|
: "";
|
|
|
|
// Replace existing generic meta description
|
|
if (metaDescription) {
|
|
page.contents = page.contents.replace(
|
|
/<meta name="description" content="Documentation for [^"]*"\/>/,
|
|
metaDescription,
|
|
);
|
|
}
|
|
|
|
// Build favicon tags with correct relative path
|
|
const depth = page.url.split("/").length - 1;
|
|
const base = depth > 0 ? "../".repeat(depth) : "./";
|
|
const faviconTags = [
|
|
`<link rel="icon" href="${base}favicons/favicon-32x32.png" type="image/png" sizes="32x32" media="(prefers-color-scheme: light)"/>`,
|
|
`<link rel="icon" href="${base}favicons/favicon-16x16.png" type="image/png" sizes="16x16" media="(prefers-color-scheme: light)"/>`,
|
|
`<link rel="shortcut icon" href="${base}favicons/favicon.ico" type="image/x-icon" media="(prefers-color-scheme: light)"/>`,
|
|
`<link rel="icon" href="${base}favicons/favicon-dark-32x32.png" type="image/png" sizes="32x32" media="(prefers-color-scheme: dark)"/>`,
|
|
`<link rel="icon" href="${base}favicons/favicon-dark-16x16.png" type="image/png" sizes="16x16" media="(prefers-color-scheme: dark)"/>`,
|
|
`<link rel="shortcut icon" href="${base}favicons/favicon-dark.ico" type="image/x-icon" media="(prefers-color-scheme: dark)"/>`,
|
|
`<link rel="apple-touch-icon" href="${base}favicons/apple-touch-icon.png" type="image/png" sizes="180x180"/>`,
|
|
].join("\n");
|
|
|
|
// Inject favicons and JSON-LD before </head>
|
|
const headInjections = [faviconTags, jsonLdScript]
|
|
.filter(Boolean)
|
|
.join("\n");
|
|
|
|
page.contents = page.contents.replace(
|
|
"</head>",
|
|
headInjections + "\n</head>",
|
|
);
|
|
});
|
|
|
|
// --- Post-render: copy favicons + rename document slugs ---
|
|
app.renderer.on(Renderer.EVENT_END, (event) => {
|
|
const outDir = event.outputDirectory;
|
|
|
|
// Copy favicons to output directory
|
|
const srcFavicons = path.join(path.dirname(outDir), "favicons");
|
|
const destFavicons = path.join(outDir, "favicons");
|
|
if (fs.existsSync(srcFavicons)) {
|
|
if (!fs.existsSync(destFavicons)) fs.mkdirSync(destFavicons);
|
|
for (const file of fs.readdirSync(srcFavicons)) {
|
|
fs.copyFileSync(
|
|
path.join(srcFavicons, file),
|
|
path.join(destFavicons, file),
|
|
);
|
|
}
|
|
}
|
|
|
|
const docsDir = path.join(outDir, "documents");
|
|
|
|
if (!fs.existsSync(docsDir)) return;
|
|
|
|
// Build rename map: old filename → new slug
|
|
const renameMap = new Map();
|
|
for (const file of fs.readdirSync(docsDir)) {
|
|
if (!file.endsWith(".html")) continue;
|
|
const slug = toSlug(file);
|
|
if (slug !== file) {
|
|
renameMap.set(file, slug);
|
|
}
|
|
}
|
|
|
|
if (renameMap.size === 0) return;
|
|
|
|
// Rename the files
|
|
for (const [oldName, newName] of renameMap) {
|
|
fs.renameSync(path.join(docsDir, oldName), path.join(docsDir, newName));
|
|
}
|
|
|
|
// Build link replacement patterns (documents/OldName.html → documents/new-name.html)
|
|
const replacements = [...renameMap.entries()].map(([oldName, newName]) => ({
|
|
old: `documents/${oldName}`,
|
|
new: `documents/${newName}`,
|
|
}));
|
|
|
|
// Update all HTML files in the output directory
|
|
updateLinksRecursive(outDir, replacements);
|
|
|
|
// Update compressed JS data files (navigation + search index)
|
|
updateCompressedJsData(
|
|
path.join(outDir, "assets", "navigation.js"),
|
|
"navigationData",
|
|
replacements,
|
|
);
|
|
updateCompressedJsData(
|
|
path.join(outDir, "assets", "search.js"),
|
|
"searchData",
|
|
replacements,
|
|
);
|
|
|
|
// Update sitemap.xml
|
|
const sitemapPath = path.join(outDir, "sitemap.xml");
|
|
if (fs.existsSync(sitemapPath)) {
|
|
let sitemap = fs.readFileSync(sitemapPath, "utf8");
|
|
for (const r of replacements) {
|
|
sitemap = sitemap.replaceAll(r.old, r.new);
|
|
}
|
|
fs.writeFileSync(sitemapPath, sitemap);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Decompress a TypeDoc JS data file, replace document slugs, and recompress.
|
|
* TypeDoc stores navigation and search data as deflate-compressed base64.
|
|
* @param {string} filePath
|
|
* @param {string} varName - JS variable name (e.g. "navigationData", "searchData")
|
|
* @param {Array<{old: string, new: string}>} replacements
|
|
*/
|
|
function updateCompressedJsData(filePath, varName, replacements) {
|
|
if (!fs.existsSync(filePath)) return;
|
|
|
|
let content = fs.readFileSync(filePath, "utf8");
|
|
const pattern = new RegExp(`window\\.${varName} = "(.+)"`);
|
|
const match = content.match(pattern);
|
|
if (!match) return;
|
|
|
|
// Decompress
|
|
const buf = Buffer.from(match[1], "base64");
|
|
let json = zlib.inflateSync(buf).toString();
|
|
|
|
// Replace slugs
|
|
let changed = false;
|
|
for (const r of replacements) {
|
|
if (json.includes(r.old)) {
|
|
json = json.replaceAll(r.old, r.new);
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
if (!changed) return;
|
|
|
|
// Recompress and write
|
|
const compressed = zlib.deflateSync(json);
|
|
const encoded = compressed.toString("base64");
|
|
content = content.replace(pattern, `window.${varName} = "${encoded}"`);
|
|
fs.writeFileSync(filePath, content);
|
|
}
|
|
|
|
/**
|
|
* Recursively update links in all HTML files under a directory.
|
|
* @param {string} dir
|
|
* @param {Array<{old: string, new: string}>} replacements
|
|
*/
|
|
function updateLinksRecursive(dir, replacements) {
|
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
const fullPath = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
updateLinksRecursive(fullPath, replacements);
|
|
} else if (entry.name.endsWith(".html") || entry.name.endsWith(".js")) {
|
|
let content = fs.readFileSync(fullPath, "utf8");
|
|
let changed = false;
|
|
for (const r of replacements) {
|
|
if (content.includes(r.old)) {
|
|
content = content.replaceAll(r.old, r.new);
|
|
changed = true;
|
|
}
|
|
}
|
|
if (changed) {
|
|
fs.writeFileSync(fullPath, content);
|
|
}
|
|
}
|
|
}
|
|
}
|