#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';

import { css, faviconSvg, js, preThemeScript, themeToggleHtml } from './docs-site-assets.mjs';

const root = process.cwd();
const docsDir = path.join(root, 'docs');
const outDir = path.join(root, 'dist', 'docs-site');
const repoBase = 'https://github.com/openclaw/mcporter';
const repoEditBase = `${repoBase}/edit/main/docs`;
const cname = readCname();
const siteBase = cname ? `https://${cname}` : '';

const productName = 'mcporter';
const productTagline = 'MCP, made portable.';
const productDescription =
  'TypeScript runtime, CLI, and code-generation toolkit for the Model Context Protocol — built so AI agents and developers can call any MCP server without boilerplate.';
const brewInstall = 'npx mcporter list';

const sections = [
  ['Start', ['index.md', 'install.md', 'quickstart.md', 'config.md']],
  ['CLI', ['cli-reference.md', 'call-syntax.md', 'call-heuristic.md', 'shortcuts.md', 'logging.md']],
  ['Generators', ['cli-generator.md', 'emit-ts.md', 'tool-calling.md']],
  ['Connecting servers', ['adhoc.md', 'import.md', 'local.md', 'daemon.md', 'mcp.md']],
  ['Agents', ['agent-skills.md', 'subagent.md']],
  [
    'Operations',
    [
      'RELEASE.md',
      'manual-testing.md',
      'livetests.md',
      'hang-debug.md',
      'windows.md',
      'tmux.md',
      'known-issues.md',
      'supabase-auth-issue.md',
    ],
  ],
  ['Reference', ['spec.md', 'migration.md', 'pnpm-mcp-migration.md', 'refactor.md']],
];

// Skip these from page generation (internal notes etc.). Pages excluded here are
// neither rendered nor link-validated.
const buildExcludes = [];

fs.rmSync(outDir, { recursive: true, force: true });
fs.mkdirSync(outDir, { recursive: true });

const allPages = allMarkdown(docsDir).map((file) => {
  const rel = path.relative(docsDir, file).replaceAll(path.sep, '/');
  const raw = fs.readFileSync(file, 'utf8');
  const { frontmatter, body } = parseFrontmatter(raw);
  const cleaned = stripStrayDirectives(body);
  const title = frontmatter.title || firstHeading(cleaned) || titleize(path.basename(rel, '.md'));
  return { file, rel, title, outRel: outPath(rel, frontmatter), markdown: cleaned, frontmatter };
});

const pages = allPages.filter((page) => !buildExcludes.some((re) => re.test(page.rel)));
const pageMap = new Map(pages.map((page) => [page.rel, page]));
const permalinkMap = new Map();
for (const page of pages) {
  if (page.frontmatter.permalink) {
    permalinkMap.set(normalizePermalink(page.frontmatter.permalink), page);
  }
}

const nav = sections
  .map(([name, rels]) => ({
    name,
    pages: rels.map((rel) => pageMap.get(rel)).filter(Boolean),
  }))
  .filter((section) => section.pages.length);

// Catch-all section: any docs/*.md we didn't slot into the curated nav goes
// under "More". This keeps every doc reachable without forcing the author to
// hand-edit `sections` for every new file.
const navRels = new Set(nav.flatMap((s) => s.pages.map((p) => p.rel)));
const extras = pages
  .filter((page) => !navRels.has(page.rel) && page.rel !== 'index.md')
  .toSorted((a, b) => a.title.localeCompare(b.title));
if (extras.length) nav.push({ name: 'More', pages: extras });

const sectionByRel = new Map();
for (const section of nav) for (const page of section.pages) sectionByRel.set(page.rel, section.name);
const orderedPages = nav.flatMap((s) => s.pages);

for (const page of pages) {
  const html = markdownToHtml(page.markdown, page.rel);
  const toc = tocFromHtml(html);
  const idx = orderedPages.findIndex((p) => p.rel === page.rel);
  const prev = idx > 0 ? orderedPages[idx - 1] : null;
  const next = idx >= 0 && idx < orderedPages.length - 1 ? orderedPages[idx + 1] : null;
  const sectionName = sectionByRel.get(page.rel) || 'Reference';
  const pageOut = path.join(outDir, page.outRel);
  fs.mkdirSync(path.dirname(pageOut), { recursive: true });
  fs.writeFileSync(pageOut, layout({ page, html, toc, prev, next, sectionName }), 'utf8');
}

fs.writeFileSync(path.join(outDir, 'favicon.svg'), faviconSvg(), 'utf8');
copyStaticAsset('social-card.svg');
copyStaticAsset('social-card.png');
fs.writeFileSync(path.join(outDir, '.nojekyll'), '', 'utf8');
if (cname) fs.writeFileSync(path.join(outDir, 'CNAME'), cname, 'utf8');
validateLinks(outDir);
fs.writeFileSync(path.join(outDir, 'llms.txt'), llmsTxt(), 'utf8');
console.log(`built docs site: ${path.relative(root, outDir)}`);

function llmsTxt() {
  const origin = docsOrigin();
  const source = docsSourceUrl();
  const name = typeof productName !== 'undefined' ? productName : path.basename(root);
  const description = typeof productDescription !== 'undefined' ? productDescription : `${name} documentation index.`;
  const install = docsInstallHint();
  const docPages = docsLlmsPages().map((page) => `- ${page.title}: ${pageUrl(origin, page.outRel)}`);
  const lines = [`# ${name}`, '', description, '', 'Canonical documentation:', ...docPages];
  if (install) {
    lines.push('', 'Install:', `- ${install}`);
  }
  if (source) {
    lines.push('', `Source: ${source}`);
  }
  lines.push(
    '',
    'Guidance for agents:',
    '- Prefer the canonical documentation URLs above over README excerpts or package metadata.',
    '- Fetch only the pages needed for the current task; this is an index, not a full-site corpus.'
  );
  return `${lines.join('\n')}\n`;
}

function docsLlmsPages() {
  const seen = new Set();
  const ordered = typeof orderedPages !== 'undefined' ? orderedPages : [];
  return [...ordered, ...pages].filter((page) => page.outRel && !seen.has(page.outRel) && seen.add(page.outRel));
}

function docsOrigin() {
  const value =
    (typeof siteBase !== 'undefined' && siteBase) ||
    (typeof siteUrl !== 'undefined' && siteUrl) ||
    (typeof customDomain !== 'undefined' && customDomain ? `https://${customDomain}` : '');
  return value.replace(/\/$/, '');
}

function docsSourceUrl() {
  if (typeof repoBase !== 'undefined') return repoBase;
  if (typeof repoUrl !== 'undefined') return repoUrl;
  if (typeof repoEditBase !== 'undefined') return repoEditBase.replace(/\/edit\/main\/docs\/?$/, '');
  return '';
}

function docsInstallHint() {
  if (typeof installCommand !== 'undefined') return installCommand;
  if (typeof installLine !== 'undefined') return installLine;
  if (typeof installCmd !== 'undefined') return installCmd;
  if (typeof installSnippet !== 'undefined') return installSnippet;
  if (typeof brewInstall !== 'undefined') return brewInstall;
  return '';
}

function pageUrl(origin, outRel) {
  const normalized =
    outRel === 'index.html'
      ? ''
      : outRel.replace(/(?:^|\/)index\.html$/, (match) => (match === 'index.html' ? '' : '/'));
  if (!origin) return normalized || 'index.html';
  return normalized ? `${origin}/${normalized}` : `${origin}/`;
}

function readCname() {
  for (const candidate of [path.join(docsDir, 'CNAME'), path.join(root, 'CNAME')]) {
    if (fs.existsSync(candidate)) return fs.readFileSync(candidate, 'utf8').trim();
  }
  return '';
}

function copyStaticAsset(name) {
  const source = path.join(docsDir, name);
  if (fs.existsSync(source)) fs.copyFileSync(source, path.join(outDir, name));
}

function parseFrontmatter(raw) {
  const match = raw.match(/^---\n([\s\S]*?)\n---\n?/);
  if (!match) return { frontmatter: {}, body: raw };
  const fm = {};
  for (const line of match[1].split('\n')) {
    const m = line.match(/^([A-Za-z0-9_-]+):\s*(.*?)\s*$/);
    if (!m) continue;
    let value = m[2];
    if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
      value = value.slice(1, -1);
    }
    fm[m[1]] = value;
  }
  return { frontmatter: fm, body: raw.slice(match[0].length) };
}

function stripStrayDirectives(body) {
  return body
    .replace(/\r\n/g, '\n')
    .split('\n')
    .filter((line) => !/^\s*\{:\s*[^}]*\}\s*$/.test(line))
    .map((line) => line.replace(/\s*\{:\s*[^}]*\}\s*$/, ''))
    .join('\n');
}

function normalizePermalink(value) {
  let v = value.trim();
  if (!v) return '/';
  if (!v.startsWith('/')) v = `/${v}`;
  if (v.length > 1 && v.endsWith('/')) v = v.slice(0, -1);
  return v;
}

function allMarkdown(dir) {
  return fs
    .readdirSync(dir, { withFileTypes: true })
    .flatMap((entry) => {
      const full = path.join(dir, entry.name);
      if (entry.isDirectory()) return allMarkdown(full);
      return entry.name.endsWith('.md') ? [full] : [];
    })
    .toSorted((a, b) => a.localeCompare(b));
}

function outPath(rel, frontmatter = {}) {
  if (frontmatter.permalink) {
    const permalink = normalizePermalink(frontmatter.permalink);
    if (permalink === '/') return 'index.html';
    return `${permalink.slice(1)}/index.html`;
  }
  if (rel === 'index.md') return 'index.html';
  if (rel === 'README.md') return 'index.html';
  if (rel.endsWith('/README.md')) return rel.replace(/README\.md$/, 'index.html');
  return rel.replace(/\.md$/, '.html');
}

function firstHeading(markdown) {
  return markdown.match(/^#\s+(.+)$/m)?.[1]?.trim();
}

function titleize(input) {
  return input.replaceAll('-', ' ').replace(/\b\w/g, (m) => m.toUpperCase());
}

function markdownToHtml(markdown, currentRel) {
  const lines = markdown.replace(/\r\n/g, '\n').split('\n');
  const html = [];
  let paragraph = [];
  let list = null;
  let fence = null;
  let blockquote = [];

  const flushParagraph = () => {
    if (!paragraph.length) return;
    html.push(`<p>${inline(paragraph.join(' '), currentRel)}</p>`);
    paragraph = [];
  };
  const closeList = () => {
    if (!list) return;
    html.push(`</${list}>`);
    list = null;
  };
  const flushBlockquote = () => {
    if (!blockquote.length) return;
    const inner = markdownToHtml(blockquote.join('\n'), currentRel);
    html.push(`<blockquote>${inner}</blockquote>`);
    blockquote = [];
  };
  for (let i = 0; i < lines.length; i++) {
    const line = lines[i];
    const fenceMatch = line.match(/^```([\w+-]+)?\s*$/);
    if (fenceMatch) {
      flushParagraph();
      closeList();
      flushBlockquote();
      if (fence) {
        html.push(
          `<pre><code class="language-${escapeAttr(fence.lang)}">${escapeHtml(fence.lines.join('\n'))}</code></pre>`
        );
        fence = null;
      } else {
        fence = { lang: fenceMatch[1] || 'text', lines: [] };
      }
      continue;
    }
    if (fence) {
      fence.lines.push(line);
      continue;
    }
    if (/^>\s?/.test(line)) {
      flushParagraph();
      closeList();
      blockquote.push(line.replace(/^>\s?/, ''));
      continue;
    }
    flushBlockquote();
    if (!line.trim()) {
      flushParagraph();
      closeList();
      continue;
    }
    if (/^\s*---+\s*$/.test(line)) {
      flushParagraph();
      closeList();
      html.push('<hr>');
      continue;
    }
    const heading = line.match(/^(#{1,4})\s+(.+)$/);
    if (heading) {
      flushParagraph();
      closeList();
      const level = heading[1].length;
      const text = heading[2].trim();
      const id = slug(text);
      const inner = inline(text, currentRel);
      if (level === 1) {
        html.push(`<h1 id="${id}">${inner}</h1>`);
      } else {
        html.push(
          `<h${level} id="${id}"><a class="anchor" href="#${id}" aria-label="Anchor link">#</a>${inner}</h${level}>`
        );
      }
      continue;
    }
    if (
      line.trimStart().startsWith('|') &&
      line.includes('|', line.indexOf('|') + 1) &&
      isDivider(lines[i + 1] || '')
    ) {
      flushParagraph();
      closeList();
      const header = splitRow(line);
      const aligns = splitRow(lines[i + 1]).map((cell) => {
        const left = cell.startsWith(':');
        const right = cell.endsWith(':');
        return right && left ? 'center' : right ? 'right' : left ? 'left' : '';
      });
      i += 1;
      const rows = [];
      while (i + 1 < lines.length && lines[i + 1].trimStart().startsWith('|')) {
        i += 1;
        rows.push(splitRow(lines[i]));
      }
      const th = header
        .map((c, idx) => `<th${aligns[idx] ? ` style="text-align:${aligns[idx]}"` : ''}>${inline(c, currentRel)}</th>`)
        .join('');
      const tb = rows
        .map(
          (r) =>
            `<tr>${r.map((c, idx) => `<td${aligns[idx] ? ` style="text-align:${aligns[idx]}"` : ''}>${inline(c, currentRel)}</td>`).join('')}</tr>`
        )
        .join('');
      html.push(`<table><thead><tr>${th}</tr></thead><tbody>${tb}</tbody></table>`);
      continue;
    }
    const bullet = line.match(/^\s*-\s+(.+)$/);
    const numbered = line.match(/^\s*\d+\.\s+(.+)$/);
    if (bullet || numbered) {
      flushParagraph();
      const tag = bullet ? 'ul' : 'ol';
      if (list && list !== tag) closeList();
      if (!list) {
        list = tag;
        html.push(`<${tag}>`);
      }
      html.push(`<li>${inline((bullet || numbered)[1], currentRel)}</li>`);
      continue;
    }
    paragraph.push(line.trim());
  }
  flushParagraph();
  closeList();
  flushBlockquote();
  return html.join('\n');
}

function inline(text, currentRel) {
  const stash = [];
  let out = text.replace(/`([^`]+)`/g, (_, code) => {
    stash.push(`<code>${escapeHtml(code)}</code>`);
    return `\uE000${stash.length - 1}\uE000`;
  });
  out = escapeHtml(out)
    .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
    .replace(/(^|[^*])\*([^*\s][^*]*?)\*(?!\*)/g, '$1<em>$2</em>')
    .replace(/(^|[^_])_([^_\s][^_]*?)_(?!_)/g, '$1<em>$2</em>')
    .replace(
      /\[([^\]]+)\]\(([^)]+)\)/g,
      (_, label, href) => `<a href="${escapeAttr(rewriteHref(href, currentRel))}">${label}</a>`
    )
    .replace(/&lt;(https?:\/\/[^\s<>]+)&gt;/g, '<a href="$1">$1</a>');
  out = out.replace(/\\\|/g, '|');
  out = out.replace(/&lt;br&gt;/g, '<br>');
  return out.replace(/\uE000(\d+)\uE000/g, (_, i) => stash[Number(i)]);
}

function splitRow(line) {
  let trimmed = line.trim();
  if (trimmed.startsWith('|')) trimmed = trimmed.slice(1);
  if (trimmed.endsWith('|') && !trimmed.endsWith('\\|')) trimmed = trimmed.slice(0, -1);
  const cells = [];
  let current = '';
  for (let idx = 0; idx < trimmed.length; idx++) {
    const char = trimmed[idx];
    if (char === '\\' && trimmed[idx + 1] === '|') {
      current += '\\|';
      idx += 1;
      continue;
    }
    if (char === '|') {
      cells.push(current.trim().replace(/\\\|/g, '|'));
      current = '';
      continue;
    }
    current += char;
  }
  cells.push(current.trim().replace(/\\\|/g, '|'));
  return cells;
}

function isDivider(line) {
  return /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?\s*$/.test(line);
}

function rewriteHref(href, currentRel) {
  if (/^(https?:|mailto:|tel:|#)/.test(href)) return href;
  const [raw, hash = ''] = href.split('#');
  if (!raw) return hash ? `#${hash}` : '';
  if (raw.startsWith('/')) {
    const target = permalinkMap.get(normalizePermalink(raw));
    if (target) {
      const currentOut = pageMap.get(currentRel)?.outRel || outPath(currentRel);
      const out = hrefToOutRel(target.outRel, currentOut);
      return hash ? `${out}#${hash}` : out;
    }
    return href;
  }
  if (!raw.endsWith('.md')) return href;
  const from = path.posix.dirname(currentRel);
  const target = path.posix.normalize(path.posix.join(from, raw));
  let rewritten = pageMap.get(target)?.outRel || outPath(target);
  const currentOut = pageMap.get(currentRel)?.outRel || outPath(currentRel);
  rewritten = hrefToOutRel(rewritten, currentOut);
  return `${rewritten}${hash ? `#${hash}` : ''}`;
}

function tocFromHtml(html) {
  const items = [];
  const re = /<h([23]) id="([^"]+)">([\s\S]*?)<\/h[23]>/g;
  let m;
  while ((m = re.exec(html))) {
    const text = m[3]
      .replace(/<a class="anchor"[^>]*>.*?<\/a>/, '')
      .replace(/<[^>]+>/g, '')
      .trim();
    items.push({ level: Number(m[1]), id: m[2], text });
  }
  if (items.length < 2) return '';
  return `<nav class="toc" aria-label="On this page"><h2>On this page</h2>${items
    .map((i) => `<a class="toc-l${i.level}" href="#${i.id}">${escapeHtml(i.text)}</a>`)
    .join('')}</nav>`;
}

function isHomePage(page) {
  if (page.frontmatter.permalink && normalizePermalink(page.frontmatter.permalink) === '/') return true;
  return page.rel === 'index.md' || page.rel === 'README.md';
}

function homeHero(page) {
  const description = page.frontmatter.description || productDescription;
  const installRel = pageMap.get('install.md')?.outRel
    ? hrefToOutRel(pageMap.get('install.md').outRel, page.outRel)
    : 'install.html';
  const quickstartRel = pageMap.get('quickstart.md')?.outRel
    ? hrefToOutRel(pageMap.get('quickstart.md').outRel, page.outRel)
    : 'quickstart.html';
  const features = ['TypeScript runtime', 'CLI', 'Generated CLIs', 'Typed clients', 'OAuth', 'stdio + HTTP + SSE'];
  return `<header class="home-hero">
        <p class="eyebrow">Model Context Protocol · Toolkit</p>
        <h1>${escapeHtml(productTagline)}</h1>
        <p class="lede">${escapeHtml(description)}</p>
        <div class="home-cta">
          <a class="btn btn-primary" href="${quickstartRel}">Quickstart</a>
          <a class="btn btn-ghost" href="${repoBase}" rel="noopener">GitHub</a>
          <div class="home-install" aria-label="Try with npx">
            <span class="prompt" aria-hidden="true">$</span>
            <code>${escapeHtml(brewInstall)}</code>
          </div>
        </div>
        <div class="home-services" aria-label="Capabilities">
          ${features.map((s) => `<span>${escapeHtml(s)}</span>`).join('')}
        </div>
        <p class="muted"><a href="${installRel}">Install options →</a></p>
      </header>`;
}

function standardHero(page, sectionName, editUrl) {
  return `<header class="hero">
        <div class="hero-text">
          <p class="eyebrow">${escapeHtml(sectionName)}</p>
          <h1>${escapeHtml(page.title)}</h1>
        </div>
        <div class="hero-meta">
          <a class="repo" href="${repoBase}" rel="noopener">GitHub</a>
          <a class="edit" href="${escapeAttr(editUrl)}" rel="noopener">Edit page</a>
        </div>
      </header>`;
}

function layout({ page, html, toc, prev, next, sectionName }) {
  const depth = page.outRel.split('/').length - 1;
  const rootPrefix = depth ? '../'.repeat(depth) : '';
  const editUrl = `${repoEditBase}/${page.rel}`;
  const home = isHomePage(page);
  const prevNext = !home && (prev || next) ? pageNavHtml(prev, next, page.outRel) : '';
  const heroBlock = home ? homeHero(page) : standardHero(page, sectionName, editUrl);
  const articleClass = home ? 'doc doc-home' : 'doc';
  const tocBlock = home ? '' : toc;
  const titleSuffix = home ? `${productName} — ${productTagline}` : `${page.title} — ${productName}`;
  const description =
    page.frontmatter.description || (home ? productDescription : `${page.title} — ${productName} documentation.`);
  const canonicalUrl = pageCanonicalUrl(page);
  const socialImage = siteBase ? `${siteBase}/social-card.png` : `${rootPrefix}social-card.png`;
  const socialMeta = [
    ['link', 'rel', 'canonical', 'href', canonicalUrl],
    ['meta', 'property', 'og:type', 'content', 'website'],
    ['meta', 'property', 'og:site_name', 'content', productName],
    ['meta', 'property', 'og:title', 'content', titleSuffix],
    ['meta', 'property', 'og:description', 'content', description],
    ['meta', 'property', 'og:url', 'content', canonicalUrl],
    ['meta', 'property', 'og:image', 'content', socialImage],
    ['meta', 'property', 'og:image:width', 'content', '1200'],
    ['meta', 'property', 'og:image:height', 'content', '630'],
    ['meta', 'name', 'twitter:card', 'content', 'summary_large_image'],
    ['meta', 'name', 'twitter:title', 'content', titleSuffix],
    ['meta', 'name', 'twitter:description', 'content', description],
    ['meta', 'name', 'twitter:image', 'content', socialImage],
  ]
    .map(tagHtml)
    .join('\n  ');
  return `<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>${escapeHtml(titleSuffix)}</title>
  <meta name="description" content="${escapeAttr(description)}">
  ${socialMeta}
  <link rel="icon" href="${rootPrefix}favicon.svg" type="image/svg+xml">
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
  <script>${preThemeScript()}</script>
  <style>${css()}</style>
</head>
<body${home ? ' class="home"' : ''}>
  <button class="nav-toggle" type="button" aria-label="Toggle navigation" aria-expanded="false">
    <span aria-hidden="true"></span><span aria-hidden="true"></span><span aria-hidden="true"></span>
  </button>
  <div class="shell">
    <aside class="sidebar">
      <div class="sidebar-head">
        <a class="brand" href="${hrefToOutRel('index.html', page.outRel)}" aria-label="${productName} docs home">
          <span class="mark" aria-hidden="true"></span>
          <span><strong>${escapeHtml(productName)}</strong><small>MCP toolkit docs</small></span>
        </a>
        ${themeToggleHtml()}
      </div>
      <label class="search"><span>Search</span><input id="doc-search" type="search" placeholder="call, generate-cli, oauth"></label>
      <nav>${navHtml(page)}</nav>
    </aside>
    <main>
      ${heroBlock}
      <div class="doc-grid${home ? ' doc-grid-home' : ''}">
        <article class="${articleClass}">${html}${prevNext}</article>
        ${tocBlock}
      </div>
    </main>
  </div>
  <script>${js()}</script>
</body>
</html>`;
}

function pageCanonicalUrl(page) {
  if (!siteBase) return page.outRel;
  if (page.outRel === 'index.html') return `${siteBase}/`;
  const rel = page.outRel.endsWith('/index.html') ? page.outRel.slice(0, -'index.html'.length) : page.outRel;
  return `${siteBase}/${rel}`;
}

function tagHtml([tag, k1, v1, k2, v2]) {
  return tag === 'link'
    ? `<link ${k1}="${v1}" ${k2}="${escapeAttr(v2)}">`
    : `<meta ${k1}="${v1}" ${k2}="${escapeAttr(v2)}">`;
}

function pageNavHtml(prev, next, currentOutRel) {
  const cell = (page, dir) => {
    if (!page) return '';
    return `<a class="page-nav-${dir}" href="${hrefToOutRel(page.outRel, currentOutRel)}"><small>${dir === 'prev' ? 'Previous' : 'Next'}</small><span>${escapeHtml(page.title)}</span></a>`;
  };
  return `<nav class="page-nav" aria-label="Pager">${cell(prev, 'prev')}${cell(next, 'next')}</nav>`;
}

function navHtml(currentPage) {
  return nav
    .map(
      (section) =>
        `<section><h2>${escapeHtml(section.name)}</h2>${section.pages
          .map((page) => {
            const href = hrefToOutRel(page.outRel, currentPage.outRel);
            const active = page.rel === currentPage.rel ? ' active' : '';
            return `<a class="nav-link${active}" href="${href}">${escapeHtml(navTitle(page))}</a>`;
          })
          .join('')}</section>`
    )
    .join('');
}

function navTitle(page) {
  if (page.rel === 'index.md') return 'Overview';
  return page.title.replace(/^`mcporter\s*/, '').replace(/`$/, '');
}

function hrefToOutRel(targetOutRel, currentOutRel) {
  const currentDir = path.posix.dirname(currentOutRel);
  if (targetOutRel.endsWith('/index.html')) {
    const targetDir = targetOutRel.slice(0, -'index.html'.length);
    const rel = path.posix.relative(currentDir, targetDir || '.') || '.';
    return rel.endsWith('/') ? rel : `${rel}/`;
  }
  if (targetOutRel === 'index.html') {
    const rel = path.posix.relative(currentDir, '.') || '.';
    return rel.endsWith('/') ? rel : `${rel}/`;
  }
  return path.posix.relative(currentDir, targetOutRel) || path.posix.basename(targetOutRel);
}

function slug(text) {
  return text
    .toLowerCase()
    .replace(/`/g, '')
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-|-$/g, '');
}

function escapeHtml(value) {
  return String(value ?? '').replace(
    /[&<>"']/g,
    (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char]
  );
}

function escapeAttr(value) {
  return escapeHtml(value);
}

function validateLinks(outputDir) {
  const failures = [];
  const placeholderHrefs = /^(url|path|file|dir|name)$/i;
  for (const file of allHtml(outputDir)) {
    const html = fs.readFileSync(file, 'utf8');
    for (const match of html.matchAll(/href="([^"]+)"/g)) {
      const href = match[1];
      if (/^(#|https?:|mailto:|tel:|javascript:)/.test(href)) continue;
      if (placeholderHrefs.test(href)) continue;
      const [rawPath, anchor = ''] = href.split('#');
      const targetPath = rawPath ? path.resolve(path.dirname(file), rawPath) : file;
      const target =
        fs.existsSync(targetPath) && fs.statSync(targetPath).isDirectory()
          ? path.join(targetPath, 'index.html')
          : targetPath;
      if (!fs.existsSync(target)) {
        failures.push(`${path.relative(outputDir, file)}: ${href} -> missing ${path.relative(outputDir, target)}`);
        continue;
      }
      if (anchor) {
        const targetHtml = fs.readFileSync(target, 'utf8');
        if (!targetHtml.includes(`id="${anchor}"`) && !targetHtml.includes(`name="${anchor}"`)) {
          failures.push(`${path.relative(outputDir, file)}: ${href} -> missing anchor`);
        }
      }
    }
  }
  if (failures.length) {
    throw new Error(`broken docs links:\n${failures.join('\n')}`);
  }
}

function allHtml(dir) {
  return fs
    .readdirSync(dir, { withFileTypes: true })
    .flatMap((entry) => {
      const full = path.join(dir, entry.name);
      if (entry.isDirectory()) return allHtml(full);
      return entry.name.endsWith('.html') ? [full] : [];
    })
    .toSorted((a, b) => a.localeCompare(b));
}
