blob: 593eb4a4c5c166b0713e6ae435ed8721224904cc [file] [edit]
// Copyright (C) 2026 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Renders the docs markdown into HTML.
//
// This used to be a one-process-per-page CLI (markdown_render.js) driven by a
// GN `md_to_html` template. It is now a module: build.mjs renders every page in
// a single process. The two consequences worth knowing about:
//
// - No module-level mutable state. Each render gets its own context, so pages
// can be rendered concurrently without leaking `title` into each other.
// - Images referenced by a page are *returned* (ctx.assets) rather than copied
// to disk as a side effect. That list used to be serialized into a ninja
// depfile so ninja could re-render a page when an image changed; in-process
// it is just a return value that feeds the next build's cache signature.
import fs from "node:fs";
import path from "node:path";
import ejs from "ejs";
import { Renderer, marked } from "marked";
import hljs from "highlight.js";
import { headingAnchor } from "./md_utils.mjs";
const CS_BASE_URL = "https://github.com/google/perfetto/blob/main/";
const SRC_DIR = path.dirname(new URL(import.meta.url).pathname);
export const ROOT_DIR = path.dirname(path.dirname(path.dirname(SRC_DIR)));
// fs.existsSync() memo for the dead-link checker. A doc links to hundreds of
// targets and watch mode re-renders often, so this saves a lot of stat()s.
// Cleared by resetLinkCache() whenever the watcher sees a change.
const existsCache = new Map();
function existsCached(absPath) {
let v = existsCache.get(absPath);
if (v === undefined) {
v = fs.existsSync(absPath);
existsCache.set(absPath, v);
}
return v;
}
export function resetLinkCache() {
existsCache.clear();
}
// Creates the mutable per-render state. `mdFile` is the absolute path of the
// markdown being rendered; it anchors relative link resolution.
export function newContext(mdFile, blogSlug) {
return {
mdFile,
title: "",
// Set only when rendering a blog post. Switches image and link handling:
// posts reference images by bare filename and link to /blog/<slug>.
blogSlug,
// sitePath -> absolute source path, for every image the page references.
assets: new Map(),
};
}
function hrefInDocs(ctx, href) {
if (href.match(/^(https?:)|^(mailto:)|^#/)) {
return undefined;
}
let pathFromRoot;
if (href.startsWith("/")) {
pathFromRoot = href;
} else {
const curDocDir = "/" + path.relative(ROOT_DIR, path.dirname(ctx.mdFile));
pathFromRoot = path.join(curDocDir, href);
}
if (pathFromRoot.startsWith("/docs/")) {
return pathFromRoot;
}
return undefined;
}
function assertNoDeadLink(ctx, relPathFromRoot) {
relPathFromRoot = relPathFromRoot.replace(/\#.*$/g, ""); // Remove #line.
// Skip check for build-time generated reference pages.
if (relPathFromRoot.endsWith(".autogen")) return;
const fullPath = path.join(ROOT_DIR, relPathFromRoot);
if (!existsCached(fullPath) && !existsCached(fullPath + ".md")) {
// The source file isn't named here: build.mjs prefixes it, and marked
// re-wraps whatever we throw.
throw new Error(`Dead link: ${relPathFromRoot}`);
}
}
function renderHeading(ctx, text, level) {
// The anchor id is derived by headingAnchor() (shared with search_index.mjs
// so search deep-links stay in sync). Note the right-hand-side TOC is
// dynamically generated from these anchors (explicit or implicit).
if (level === 1 && !ctx.title) {
ctx.title = text;
}
const anchorId = headingAnchor(text, level);
// Strip an explicit {#anchor} marker from the visible heading text.
text = text.replace(/{#[\w-_.]+}/, "");
let anchor = "";
if (anchorId) {
anchor = `<a name="${anchorId}" class="anchor" href="#${anchorId}"></a>`;
}
return `<h${level}>${anchor}${text}</h${level}>`;
}
function renderLink(ctx, originalLinkFn, href, title, text) {
if (ctx.blogSlug !== undefined) {
// Without this, /blog/other-post would fall through to the source-code
// branch below, get rewritten to github.com and then fail the dead-link
// check. Docs and source links still work from a post.
if (href.startsWith("/blog/")) {
return originalLinkFn(href, title, text);
}
if (!href.match(/^(https?:)|^(mailto:)|^#|^\//)) {
throw new Error(
`Relative link '${href}'. Blog posts link to other posts as ` +
`/blog/<slug> and to docs as /docs/<path>.`,
);
}
}
if (href.startsWith("../")) {
throw new Error(
`Don\'t use relative paths in docs, always use /docs/xxx ` +
`or /src/xxx for both links to docs and code (${href})`,
);
}
const docsHref = hrefInDocs(ctx, href);
let sourceCodeLink = undefined;
if (docsHref !== undefined) {
// Check that the target doc exists. Skip the check on /reference/ files
// that are typically generated at build time.
assertNoDeadLink(ctx, docsHref);
href = docsHref.replace(/[.](md|autogen)\b/, "");
href = href.replace(/\/README$/, "/");
} else if (href.startsWith("/") && !href.startsWith("//")) {
// /tools/xxx -> github/tools/xxx.
sourceCodeLink = href;
}
if (sourceCodeLink !== undefined) {
// Fix up line anchors for GitHub link: #42 -> #L42.
sourceCodeLink = sourceCodeLink.replace(/#(\d+)$/g, "#L$1");
// Strip the / prefix from the link, as CS_BASE_URL already endsin '/'.
sourceCodeLink = sourceCodeLink.replace(/^[/]/, "");
assertNoDeadLink(ctx, sourceCodeLink);
href = CS_BASE_URL + sourceCodeLink;
}
return originalLinkFn(href, title, text);
}
function renderCode(text, lang) {
if (lang === "mermaid") {
return `<div class="mermaid">${text}</div>`;
}
let hlHtml = "";
if (lang) {
// ignoreIllegals is passed explicitly: it defaulted to false in the
// deprecated highlight(lang, code) form this replaced, but defaults to true
// in the object form. Verified to produce byte-identical output site-wide.
hlHtml = hljs.highlight(text, {
language: lang,
ignoreIllegals: false,
}).value;
} else {
hlHtml = hljs.highlightAuto(text).value;
}
// Wrap in a positioned container so a "copy" button can be overlaid in the
// top-right corner (see setupCodeCopy() in script.js).
return (
`<div class="code-block-wrapper">` +
`<button class="code-copy-button" type="button" aria-label="Copy code" ` +
`title="Copy to clipboard"></button>` +
`<code class="hljs code-block">${hlHtml}</code>` +
`</div>`
);
}
// Release notes routinely carry a screen recording. Markdown has no video
// syntax, so `![alt](demo.mp4)` is written like an image and turned into a
// player here -- which also means the file goes through the normal asset
// pipeline and gets copied to the output like any screenshot.
const VIDEO_EXTS = [".mp4", ".webm"];
function isVideo(href) {
return VIDEO_EXTS.some((e) => href.toLowerCase().endsWith(e));
}
function renderVideo(href, text) {
const alt = text ? ` aria-label="${text}"` : "";
return (
`<video class="md-video" controls preload="metadata"${alt}>` +
`<source src="${href}">` +
`</video>`
);
}
function renderImage(ctx, originalImgFn, href, title, text) {
if (ctx.blogSlug !== undefined && !href.match(/^(https?:)|^#|^\//)) {
const src = path.join(path.dirname(ctx.mdFile), href);
if (!existsCached(src)) {
throw new Error(`Missing image '${href}'`);
}
ctx.assets.set(`blog/media/${ctx.blogSlug}/${href}`, src);
href = `/blog/media/${ctx.blogSlug}/${href}`;
return isVideo(href)
? renderVideo(href, text)
: originalImgFn(href, title, text);
}
const docsHref = hrefInDocs(ctx, href);
if (docsHref !== undefined) {
// Record it rather than copying: build.mjs owns the output map, and this
// list doubles as the page's dynamic dependency set (the old depfile).
ctx.assets.set(docsHref.replace(/^\//, ""), ROOT_DIR + docsHref);
}
if (isVideo(href)) {
return renderVideo(href, text);
}
if (href.endsWith(".svg")) {
return `<object type="image/svg+xml" data="${href}"></object>`;
}
return originalImgFn(href, title, text);
}
function renderListItem(text) {
// Detect a trailing {.class1 .class2} attribute block (used in toc.md to
// annotate audience, e.g. {.tag-android .tag-linux}). Hoist the classes
// onto the <li> so the sidebar can filter with pure CSS.
const m = text.match(
/\s*\{(\.[a-z][a-z0-9-]*(?:\s+\.[a-z][a-z0-9-]*)*)\}(\s*<\/p>)?\s*$/,
);
if (m) {
const cls = m[1]
.split(/\s+/)
.map((c) => c.slice(1))
.join(" ");
const tail = m[2] || "";
return `<li class="${cls}">${text.slice(0, m.index)}${tail}</li>\n`;
}
return `<li>${text}</li>\n`;
}
function renderParagraph(text) {
let cssClass = "";
if (text.startsWith("NOTE:")) {
cssClass = "note";
} else if (text.startsWith("TIP:")) {
cssClass = "tip";
} else if (text.startsWith("TODO:") || text.startsWith("FIXME:")) {
cssClass = "todo";
} else if (text.startsWith("WARNING:")) {
cssClass = "warning";
} else if (text.startsWith("Summary:")) {
cssClass = "summary";
}
if (cssClass != "") {
cssClass = ` class="callout ${cssClass}"`;
}
// Rudimentary support of definition lists.
var colonStart = text.search("\n:");
if (colonStart != -1) {
var key = text.substring(0, colonStart);
var value = text.substring(colonStart + 2);
return `<dl><dt><p>${key}</p></dt><dd><p>${value}</p></dd></dl>`;
}
return `<p${cssClass}>${text}</p>\n`;
}
function renderHtml(ctx, originalHtmlFn, raw) {
if (!raw.trim().startsWith("<?tabs>")) {
return originalHtmlFn(raw);
}
const sanitized = raw.replace("<?tabs>", "").replace("</tabs?>", "");
const tabs = sanitized
.split("TAB: ")
.map((x) => x.trim())
.filter((x) => x.length !== 0);
const buttons = [];
const content = [];
for (const tab of tabs) {
const eol = tab.indexOf("\n");
buttons.push(tab.substring(0, eol));
// Recurses with the same ctx, so nested links/images are resolved against
// the same source file and land in the same asset set.
content.push(renderMarkdown(tab.substring(eol + 1), ctx));
}
return `
<div class="tab-box">
<div class="tab-buttons">
${buttons
.map((x) => `<button class="tab-button">${x}</button>`)
.join("\n")}
</div>
${content
.map((x) => `<div class="tab-content"><p>${x}</p></div>`)
.join("\n")}
</div>
`;
}
// Renders markdown to HTML, accumulating title/assets into `ctx`.
export function renderMarkdown(rawMarkdown, ctx) {
const renderer = new Renderer();
const originalLinkFn = renderer.link.bind(renderer);
const originalImgFn = renderer.image.bind(renderer);
renderer.link = (hr, ti, te) => renderLink(ctx, originalLinkFn, hr, ti, te);
renderer.image = (hr, ti, te) => renderImage(ctx, originalImgFn, hr, ti, te);
renderer.code = renderCode;
renderer.heading = (text, level) => renderHeading(ctx, text, level);
renderer.paragraph = renderParagraph;
renderer.listitem = renderListItem;
const originalHtmlFn = renderer.html.bind(renderer);
renderer.html = (html) => renderHtml(ctx, originalHtmlFn, html);
// mangle:false disables marked's obfuscation of autolinked email addresses.
// That obfuscation picks hex-vs-decimal entities with Math.random(), so it
// made every build byte-different for no real benefit -- the addresses are
// plain text in the markdown source on GitHub anyway.
return marked.parse(rawMarkdown, { renderer: renderer, mangle: false });
}
// EJS templates are compiled once and reused across all ~150 pages.
const templateCache = new Map();
export function compiledTemplate(templatePath) {
let fn = templateCache.get(templatePath);
if (fn === undefined) {
const src = fs.readFileSync(templatePath, "utf8");
fn = ejs.compile(src, { filename: templatePath });
templateCache.set(templatePath, fn);
}
return fn;
}
export function resetTemplateCache() {
templateCache.clear();
}
const FALLBACK_TITLE =
"Perfetto - System profiling, app tracing and trace analysis";
// Renders one page. Returns {html, assets, title}.
// markdown raw markdown source, or null for template-only pages (index).
// mdFile absolute path the markdown came from (for link resolution).
// templatePath EJS template, or null to emit the bare markdown HTML (_nav).
// sitePath output path relative to the site root, e.g. "docs/faq".
// nav the rendered _nav.html fragment, or undefined.
export function renderPage({
markdown,
mdFile,
templatePath,
sitePath,
nav,
post,
}) {
const ctx = newContext(mdFile, post === undefined ? undefined : post.slug);
const markdownHtml = markdown === null ? "" : renderMarkdown(markdown, ctx);
if (!templatePath) {
return { html: markdownHtml, assets: ctx.assets, title: ctx.title };
}
// `post` is set on blog pages: a post record, or {isIndex, posts} for the
// blog index. A post's title comes from its front matter rather than the
// first <h1>, so it is already known here; the docs take whatever
// renderHeading() saw.
let title = ctx.title;
let suffix = " - Perfetto Tracing Docs";
if (post !== undefined) {
title = post.isIndex ? "Blog" : post.title;
suffix = post.isIndex ? " - Perfetto" : " - Perfetto Blog";
}
const templateData = {
markdown: markdownHtml,
title: title ? `${title}${suffix}` : FALLBACK_TITLE,
fileName: "/" + sitePath,
post,
description: post === undefined ? undefined : post.summary,
ogType: post !== undefined && !post.isIndex ? "article" : undefined,
// The index has no cover of its own; fall back to the newest post's.
ogImage:
post === undefined
? undefined
: post.cover !== undefined
? `https://perfetto.dev/${post.cover.sitePath}`
: post.posts.length > 0
? `https://perfetto.dev/${post.posts[0].cover.sitePath}`
: undefined,
};
if (nav !== undefined) {
templateData["nav"] = nav;
}
const html = compiledTemplate(templatePath)(templateData);
return { html, assets: ctx.assets, title: ctx.title };
}