/* ─── Section icons ─────────────────────────────────────────────────────── */ const SECTION_ICON_PATHS = { 'book-open': '', braces: '', cloud: '', 'kanban-square': '', 'layout-dashboard': '', network: '', package: '', plug: '', puzzle: '', rocket: '', 'settings-2': '', shield: '', terminal: '', unplug: '', wrench: '', }; function sectionIconTag(section) { const icon = SECTION_ICON_PATHS[section?.icon] || SECTION_ICON_PATHS['book-open']; return ``; } function renderLucideIcons() {} function getSectionKind(section) { if (section && typeof section === 'object') return section.tier || 'Guides'; return 'Guides'; } const TIER_ORDER = ['Learn', 'Administration', 'Reference']; /* ─── Theme bootstrap (before first paint of body) ──────────────────────── */ (function() { const saved = localStorage.getItem('pc-guides-theme') || 'dark'; if (saved === 'dark') document.documentElement.setAttribute('data-theme', 'dark'); else document.documentElement.removeAttribute('data-theme'); })(); window.addEventListener('load', () => { requestAnimationFrame(() => document.documentElement.classList.add('motion-ready')); }, { once: true }); /* ─── State ─────────────────────────────────────────────────────────────── */ let navData = null; let allPages = []; let currentFile = null; let currentMarkdown = ''; let tocObserver = null; let tocScrollHandler = null; let tocResizeHandler = null; let tocDocumentClickHandler = null; let tocKeydownHandler = null; let markdownRendererPromise = null; const SEO_SITE_NAME = 'Paperclip Docs'; const SEO_DEFAULT_TITLE = 'Paperclip Docs'; const SEO_DEFAULT_DESCRIPTION = 'Guides, references, and walkthroughs for running Paperclip, an AI company operating system for agent teams, governance, budgets, and workflows.'; const APP_DIR_NAME = 'site'; const APP_BASE_PATH = (() => { const marker = `/${APP_DIR_NAME}`; const pathname = window.location.pathname; const markerIndex = pathname.indexOf(marker); if (markerIndex === -1) return ''; return pathname.slice(0, markerIndex + marker.length); })(); const APP_BASE_URL = new URL(`${APP_BASE_PATH.replace(/\/$/, '')}/`, window.location.origin); const APP_SHELL_URL = new URL('index.html', APP_BASE_URL); function ensureMarkdownRenderer() { if (window.marked) return Promise.resolve(window.marked); if (!markdownRendererPromise) { markdownRendererPromise = new Promise((resolve, reject) => { const existing = document.querySelector('script[data-markdown-renderer]'); if (existing) { existing.addEventListener('load', () => resolve(window.marked), { once: true }); existing.addEventListener('error', () => reject(new Error('Markdown renderer failed to load.')), { once: true }); return; } const script = document.createElement('script'); script.src = resolveContentUrl('vendor/marked.min.js'); script.defer = true; script.dataset.markdownRenderer = ''; script.addEventListener('load', () => { if (window.marked) resolve(window.marked); else reject(new Error('Markdown renderer failed to initialize.')); }, { once: true }); script.addEventListener('error', () => reject(new Error('Markdown renderer failed to load.')), { once: true }); document.head.appendChild(script); }); } return markdownRendererPromise; } async function fetchMarkdown(file) { const res = await fetch(resolveContentUrl(file)); if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.text(); } /* ─── Screenshot src resolver ───────────────────────────────────────────── */ function resolveScreenshotSrc(src) { const theme = document.documentElement.dataset.theme || 'light'; // New-style: screenshots/light/group/file.png or screenshots/dark/group/file.png const newMatch = src.match(/screenshots\/(?:light|dark)\/([^/]+\/[^/]+\.png)$/); if (newMatch) return resolveContentUrl(`../docs/user-guides/screenshots/${theme}/${newMatch[1]}`); // Legacy-style: images/group/file.png const legacyMatch = src.match(/images\/([^/]+\/[^/]+\.png)$/); if (legacyMatch) return resolveContentUrl(`../docs/user-guides/screenshots/${theme}/${legacyMatch[1]}`); return src; } /* ─── Shared markdown link resolution ───────────────────────────────────── */ // The three functions between the DOC-LINK-RESOLVER markers define what a // relative markdown href means, and they have a twin in site/build-release.mjs // (normalizeDocPath / docLinkBaseDir / resolveDocLinkTarget, used by // resolveDocHref). The build rewrites links into canonical routes so crawlers // index correct bytes; this client resolves the same links again at runtime // against markdown it fetched itself. When the two disagree, a link looks // right in the pre-render and breaks on click. // // scripts/verify-doc-link-resolution.mjs lifts this block straight out of the // source and pins both copies to one shared case table. // DOC-LINK-RESOLVER:BEGIN function normalizeDocPath(path) { const normalized = []; for (const segment of path.split('/')) { if (!segment || segment === '.') continue; if (segment === '..') { if (normalized.length && normalized[normalized.length - 1] !== '..') { normalized.pop(); } else { normalized.push('..'); } continue; } normalized.push(segment); } return normalized.join('/'); } function docLinkBaseDir(sourceFile) { // A document at the docs root has no directory to resolve against. Trimming // with a `/`-anchored pattern matched nothing for those files and left the // filename in the base, so `connectors.md` + `connectors/access-model.md` // resolved to `connectors.mdconnectors/access-model.md` (PAP-18407). return typeof sourceFile === 'string' && sourceFile.includes('/') ? sourceFile.replace(/\/[^/]*$/, '') : ''; } function resolveDocLinkTarget(docHref, sourceFile) { const baseDir = docLinkBaseDir(sourceFile); return normalizeDocPath(baseDir ? `${baseDir}/${docHref}` : docHref); } // DOC-LINK-RESOLVER:END function normalizeRouteKey(value) { return value.replace(/^\/+/, '').replace(/\/+$/, ''); } function derivePageSlug(file) { const normalized = normalizeDocPath(file).replace(/^(\.\.\/)+/, ''); const withoutExtension = normalized.replace(/\.md$/, ''); if (withoutExtension.startsWith('user-guides/guides/')) { return withoutExtension.slice('user-guides/guides/'.length); } return withoutExtension; } function isNavPage(node) { return Boolean(node && typeof node === 'object' && typeof node.file === 'string'); } function getNavChildren(node) { return Array.isArray(node?.pages) ? node.pages : []; } function countNavPages(nodes) { return getNavChildren({ pages: nodes }).reduce((count, node) => { if (isNavPage(node)) return count + 1; return count + countNavPages(getNavChildren(node)); }, 0); } function getFirstNavPage(nodes) { for (const node of getNavChildren({ pages: nodes })) { if (isNavPage(node)) return node; const firstChild = getFirstNavPage(getNavChildren(node)); if (firstChild) return firstChild; } return null; } function pageTrail(page) { return Array.isArray(page?.navTrail) && page.navTrail.length ? page.navTrail : [page?.sectionTitle, page?.title].filter(Boolean); } function pageGroupLabel(page) { const trail = pageTrail(page); return trail.slice(0, -1).join(' / ') || page?.sectionTitle || ''; } function resolveContentUrl(path) { return new URL(path, APP_SHELL_URL).toString(); } function buildRouteValue(page, headingId = null) { return headingId ? `${page.slug}/${headingId}` : page.slug; } function getRouteUrl(routeValue) { const normalized = normalizeRouteKey(routeValue); const basePath = APP_BASE_URL.pathname.replace(/\/$/, ''); return normalized ? `${basePath}/${normalized}/` : `${basePath}/`; } function getPageUrl(page) { return getRouteUrl(buildRouteValue(page)); } function getPageHeadingUrl(page, headingId) { return `${getPageUrl(page)}#${encodeURIComponent(headingId)}`; } function getAbsoluteUrl(path) { return new URL(path, window.location.origin).toString(); } function getAbsolutePageUrl(page) { return getAbsoluteUrl(page ? getPageUrl(page) : getRouteUrl('')); } function setHeadElement(selector, tagName, attrs) { let el = document.head.querySelector(selector); if (!el) { el = document.createElement(tagName); el.dataset.seoManaged = ''; document.head.appendChild(el); } for (const [name, value] of Object.entries(attrs)) { el.setAttribute(name, value); } return el; } function setSeoMetadata({ title, description, url, type = 'website' }) { const cleanTitle = title || SEO_DEFAULT_TITLE; const cleanDescription = description || SEO_DEFAULT_DESCRIPTION; const cleanUrl = url || getAbsolutePageUrl(null); document.title = cleanTitle; setHeadElement('meta[name="description"]', 'meta', { name: 'description', content: cleanDescription }); setHeadElement('meta[name="robots"]', 'meta', { name: 'robots', content: 'index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1', }); setHeadElement('link[rel="canonical"]', 'link', { rel: 'canonical', href: cleanUrl }); setHeadElement('meta[property="og:type"]', 'meta', { property: 'og:type', content: type }); setHeadElement('meta[property="og:site_name"]', 'meta', { property: 'og:site_name', content: SEO_SITE_NAME }); setHeadElement('meta[property="og:title"]', 'meta', { property: 'og:title', content: cleanTitle }); setHeadElement('meta[property="og:description"]', 'meta', { property: 'og:description', content: cleanDescription }); setHeadElement('meta[property="og:url"]', 'meta', { property: 'og:url', content: cleanUrl }); setHeadElement('meta[name="twitter:card"]', 'meta', { name: 'twitter:card', content: 'summary' }); setHeadElement('meta[name="twitter:title"]', 'meta', { name: 'twitter:title', content: cleanTitle }); setHeadElement('meta[name="twitter:description"]', 'meta', { name: 'twitter:description', content: cleanDescription }); } function markdownToDescription(md) { const plain = stripFrontmatter(md) .replace(/```[\s\S]*?```/g, ' ') .split(/\n{2,}/) .map(block => block.trim()) .find(block => block && !block.startsWith('#') && !block.startsWith('![') && !block.startsWith('|') && !block.startsWith('---') ); if (!plain) return SEO_DEFAULT_DESCRIPTION; return plain .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') .replace(/[*_`>#-]/g, ' ') .replace(/\s+/g, ' ') .trim() .slice(0, 220); } function updateLandingSeo() { setSeoMetadata({ title: SEO_DEFAULT_TITLE, description: SEO_DEFAULT_DESCRIPTION, url: getAbsolutePageUrl(null), type: 'website', }); } function updatePageSeo(page, md) { if (!page) return; // Match the server-rendered head exactly. Authored frontmatter wins, so a // client-side transition cannot leave a different title or description on // screen than the one the crawler was served for the same route. const authoredTitle = page.frontmatter?.seo_title?.trim(); const authoredDescription = page.frontmatter?.seo_description?.trim(); setSeoMetadata({ title: `${authoredTitle || page.title} | Paperclip Docs`, description: authoredDescription || markdownToDescription(md), url: getAbsolutePageUrl(page), type: 'article', }); } function getLegacyRoute() { const url = new URL(window.location.href); const hash = location.hash.startsWith('#/') ? location.hash.slice(2) : location.hash.slice(1); return url.searchParams.get('page') || hash; } function getPathRoute() { const relativePath = normalizeRouteKey(window.location.pathname.slice(APP_BASE_PATH.length)); if (!relativePath || relativePath === 'index.html') return ''; return relativePath.replace(/\/index\.html$/, ''); } function getCurrentRoute() { return getPathRoute() || getLegacyRoute(); } function getCurrentHeadingRoute() { const pathRoute = getPathRoute(); if (!pathRoute || !location.hash || location.hash.startsWith('#/')) return null; return normalizeRouteKey(decodeURIComponent(location.hash.slice(1))); } function slugifyHeadingText(text) { return text .trim() .toLowerCase() .replace(/[^\w\s-]/g, '') .replace(/\s+/g, '-') .replace(/-+/g, '-'); } function findPageByRoute(route) { const decoded = decodeURIComponent((route || '').trim()); const normalizedRoute = normalizeRouteKey(decoded); if (!normalizedRoute) return null; return allPages.find(page => page.slug === normalizedRoute || page.file === decoded || normalizeDocPath(page.file) === normalizeDocPath(decoded) ) || null; } function parseRoute(route) { const decoded = decodeURIComponent((route || '').trim()); const normalizedRoute = normalizeRouteKey(decoded); if (!normalizedRoute) return { page: null, headingId: null }; const exactPage = findPageByRoute(normalizedRoute); if (exactPage) return { page: exactPage, headingId: null }; const page = [...allPages] .sort((a, b) => b.slug.length - a.slug.length) .find(candidate => normalizedRoute.startsWith(`${candidate.slug}/`)); if (!page) return { page: null, headingId: null }; const headingId = normalizeRouteKey(normalizedRoute.slice(page.slug.length + 1)); return { page, headingId: headingId || null }; } function focusHeading(heading) { const top = heading.getBoundingClientRect().top + window.scrollY - 128; window.scrollTo({ top, behavior: 'smooth' }); heading.classList.add('heading-highlight'); heading.addEventListener('animationend', () => heading.classList.remove('heading-highlight'), { once: true }); } async function copyText(value) { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(value); return; } const textarea = document.createElement('textarea'); textarea.value = value; textarea.setAttribute('readonly', ''); textarea.style.position = 'absolute'; textarea.style.left = '-9999px'; document.body.appendChild(textarea); textarea.select(); const didCopy = document.execCommand('copy'); document.body.removeChild(textarea); if (!didCopy) throw new Error('Clipboard copy failed'); } function showCopiedState(anchor) { anchor.classList.add('is-copied'); const previousLabel = anchor.getAttribute('aria-label') || 'Copy section link'; anchor.setAttribute('aria-label', 'Copied section link'); window.setTimeout(() => { anchor.classList.remove('is-copied'); anchor.setAttribute('aria-label', previousLabel); }, 1200); } function findHeadingTarget(article, targetHeading) { if (!targetHeading) return null; const normalizedTarget = normalizeRouteKey(targetHeading); const headings = [...article.querySelectorAll('h1, h2, h3, h4, h5, h6')]; return ( headings.find(heading => heading.id === normalizedTarget) || headings.find(heading => heading.textContent.trim().toLowerCase() === targetHeading.toLowerCase()) || null ); } function decorateHeadings(article, file, unlistedPage = null) { const page = unlistedPage || allPages.find(candidate => candidate.file === file); if (!page) return; const usedIds = new Set(); article.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach(heading => { const baseId = slugifyHeadingText(heading.textContent) || heading.tagName.toLowerCase(); let nextId = baseId; let suffix = 2; while (usedIds.has(nextId)) nextId = `${baseId}-${suffix++}`; usedIds.add(nextId); heading.id = nextId; const anchor = document.createElement('a'); anchor.className = 'heading-anchor'; anchor.href = getPageHeadingUrl(page, nextId); anchor.setAttribute('aria-label', `Copy link to section ${heading.textContent.trim()}`); anchor.setAttribute('title', 'Copy section link'); anchor.innerHTML = ``; anchor.addEventListener('click', async e => { e.preventDefault(); const nextUrl = getPageHeadingUrl(page, nextId); history.pushState(null, '', nextUrl); focusHeading(heading); try { await copyText(getAbsoluteUrl(nextUrl)); showCopiedState(anchor); } catch (error) { console.error('Failed to copy section link', error); } }); heading.appendChild(anchor); }); } function decorateCodeBlocks(article) { const COPY_SVG = ''; const CHECK_SVG = ''; const DOWNLOAD_SVG = ''; article.querySelectorAll('pre').forEach(pre => { if (pre.parentElement?.classList.contains('code-wrap')) return; const wrap = document.createElement('div'); wrap.className = 'code-wrap'; pre.parentNode.insertBefore(wrap, pre); wrap.appendChild(pre); const btn = document.createElement('button'); btn.className = 'code-copy'; btn.type = 'button'; btn.setAttribute('aria-label', 'Copy code'); btn.title = 'Copy code'; btn.innerHTML = COPY_SVG; btn.addEventListener('click', async () => { try { await copyText(pre.innerText); btn.classList.add('is-copied'); btn.innerHTML = CHECK_SVG; setTimeout(() => { btn.classList.remove('is-copied'); btn.innerHTML = COPY_SVG; }, 1200); } catch {} }); wrap.appendChild(btn); // Blocks that advertise a downloadable filename (the authoritative skill // source) get a Download control beside Copy, sharing its dimensions, // styling, and hover/focus visibility (see .code-download in styles.css). const downloadable = pre.querySelector('code[data-skill-download]'); const filename = downloadable?.getAttribute('data-skill-download'); if (downloadable && filename) { const dl = document.createElement('button'); dl.className = 'code-download'; dl.type = 'button'; dl.setAttribute('aria-label', `Download ${filename}`); dl.title = `Download ${filename}`; dl.innerHTML = DOWNLOAD_SVG; dl.addEventListener('click', () => { try { const blob = new Blob([downloadable.textContent], { type: 'text/markdown' }); const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = filename; document.body.appendChild(anchor); anchor.click(); anchor.remove(); setTimeout(() => URL.revokeObjectURL(url), 0); dl.classList.add('is-copied'); dl.innerHTML = CHECK_SVG; setTimeout(() => { dl.classList.remove('is-copied'); dl.innerHTML = DOWNLOAD_SVG; }, 1200); } catch {} }); wrap.appendChild(dl); } }); } /* ─── GitHub star count (mirrors paperclip.ing) ────────────────────────── */ (function () { const REPO = 'paperclipai/paperclip'; const TTL_MS = 6 * 60 * 60 * 1000; // 6h const CACHE_KEY = 'pc-docs-star-count'; const els = document.querySelectorAll('[data-star-count]'); if (!els.length) return; function format(n) { if (n >= 1000) return (n / 1000).toFixed(n >= 10000 ? 0 : 1).replace(/\.0$/, '') + 'k'; return String(n); } function render(text) { els.forEach(el => { el.textContent = text; el.hidden = false; }); } try { const cached = localStorage.getItem(CACHE_KEY); if (cached) { const { count, ts } = JSON.parse(cached); if (typeof count === 'number') { render(format(count)); if (Date.now() - ts < TTL_MS) return; } } } catch (_) {} const refresh = () => { fetch('https://api.github.com/repos/' + REPO, { headers: { 'Accept': 'application/vnd.github.v3+json' }, }) .then(res => (res.ok ? res.json() : null)) .then(data => { if (!data || typeof data.stargazers_count !== 'number') return; render(format(data.stargazers_count)); try { localStorage.setItem(CACHE_KEY, JSON.stringify({ count: data.stargazers_count, ts: Date.now() })); } catch (_) {} }) .catch(() => {}); }; if ('requestIdleCallback' in window) { window.requestIdleCallback(refresh, { timeout: 3000 }); } else { window.setTimeout(refresh, 1500); } })(); /* ─── Theme toggle wiring ───────────────────────────────────────────────── */ document.getElementById('theme-toggle').addEventListener('click', () => { const html = document.documentElement; const isDark = html.getAttribute('data-theme') === 'dark'; const next = isDark ? 'light' : 'dark'; if (next === 'dark') html.setAttribute('data-theme', 'dark'); else html.removeAttribute('data-theme'); localStorage.setItem('pc-guides-theme', next); // Swap all visible screenshot srcs document.querySelectorAll('#article img[data-screenshot]').forEach(img => { applyScreenshotSource(img, img.dataset.screenshot); }); }); /* ─── Mobile drawer ─────────────────────────────────────────────────────── */ function openDrawer() { const drawer = document.getElementById('drawer'); const backdrop = document.getElementById('drawer-backdrop'); const hamburger = document.getElementById('hamburger'); drawer.inert = false; drawer.classList.add('is-open'); backdrop.classList.add('is-open'); drawer.setAttribute('aria-hidden', 'false'); hamburger.setAttribute('aria-expanded', 'true'); document.body.style.overflow = 'hidden'; } function closeDrawer() { const drawer = document.getElementById('drawer'); const backdrop = document.getElementById('drawer-backdrop'); const hamburger = document.getElementById('hamburger'); drawer.classList.remove('is-open'); backdrop.classList.remove('is-open'); drawer.setAttribute('aria-hidden', 'true'); drawer.inert = true; hamburger.setAttribute('aria-expanded', 'false'); document.body.style.overflow = ''; } document.getElementById('hamburger').addEventListener('click', openDrawer); document.getElementById('drawer-close').addEventListener('click', closeDrawer); document.getElementById('drawer-backdrop').addEventListener('click', closeDrawer); document.addEventListener('keydown', e => { if (e.key === 'Escape') closeDrawer(); }); window.addEventListener('resize', () => { if (window.innerWidth > 820 && document.getElementById('drawer').classList.contains('is-open')) closeDrawer(); }); /* ─── Landing <-> article view switching ────────────────────────────────── */ function docsRootUrl() { return `${APP_BASE_URL.pathname.replace(/\/$/, '')}/`; } function showLanding() { const landing = document.getElementById('landing'); // Interior documents ship without the homepage subtree, so there is no view // to swap to — go to the real docs root instead. if (!landing) { window.location.assign(docsRootUrl()); return; } landing.classList.add('is-active'); document.getElementById('article-view').classList.remove('is-active'); document.getElementById('breadcrumb').innerHTML = ''; history.replaceState(null, '', docsRootUrl()); updateLandingSeo(); } function showArticleView() { // Only the docs root ships a homepage subtree, and once it hands off to an // article the hero is dropped rather than hidden — otherwise the live DOM // would keep a second H1 and the homepage headline on an interior route. document.getElementById('landing')?.remove(); document.getElementById('article-view').classList.add('is-active'); } /* Delegated nav clicks (logo, sb-back, landing cards, quick-link footer) */ document.addEventListener('click', e => { // Home nav (logo, back-to-all-docs) const home = e.target.closest('[data-nav="home"]'); if (home) { // Without a homepage subtree on this document, let the anchor's real root // href navigate rather than intercepting into a view that does not exist. if (!document.getElementById('landing')) return; e.preventDefault(); closeDrawer(); showLanding(); window.scrollTo({ top: 0 }); return; } // Landing card -> first page of section const card = e.target.closest('[data-nav-section]'); if (card) { e.preventDefault(); const section = navData?.sections?.[Number(card.dataset.navSection)]; const firstPage = getFirstNavPage(section?.pages || []); if (firstPage) loadPage(firstPage.file); return; } // Landing quick link -> specific page const qlink = e.target.closest('[data-nav-file]'); if (qlink) { e.preventDefault(); loadPage(qlink.dataset.navFile); return; } const routeLink = e.target.closest('[data-nav="link"]'); if (routeLink) { const route = parseRoute(applyRedirect(routeLink.getAttribute('href'))); if (route.page) { e.preventDefault(); loadPage(route.page.file, route.headingId); } } }); /* ─── Search ────────────────────────────────────────────────────────────── */ let searchIndex = []; let searchFocusedIdx = -1; let searchIndexPromise = null; async function buildSearchIndex() { const tasks = allPages.map(async page => { try { const md = await fetchMarkdown(page.file); // Extract headings const headings = []; const hRe = /^#{1,3}\s+(.+)$/gm; let m; while ((m = hRe.exec(md)) !== null) headings.push(m[1].trim()); // Strip to plain text for snippet search const text = md .replace(/^---[\s\S]*?---\n?/, '') .replace(/```[\s\S]*?```/g, ' ') .replace(/^\s*#{1,6}\s+.+$/gm, '') .replace(/[*_`~[\]()#>|]/g, ' ') .replace(/https?:\S+/g, '') .replace(/\s+/g, ' ') .trim(); return { file: page.file, sectionTitle: pageGroupLabel(page), pageTitle: page.title, headings, text }; } catch { return null; } }); searchIndex = (await Promise.all(tasks)).filter(Boolean); return searchIndex; } function ensureSearchIndex() { if (searchIndex.length) return Promise.resolve(searchIndex); if (!searchIndexPromise) searchIndexPromise = buildSearchIndex(); return searchIndexPromise; } function searchGuides(query) { const q = query.trim().toLowerCase(); if (!q) return []; const results = []; for (const entry of searchIndex) { let score = 0, matchHeading = '', snippet = ''; if (entry.pageTitle.toLowerCase().includes(q)) score += 100; if (entry.sectionTitle.toLowerCase().includes(q)) score += 20; for (const h of entry.headings) { if (h.toLowerCase().includes(q)) { score += 50; if (!matchHeading) matchHeading = h; break; } } const textL = entry.text.toLowerCase(); const textIdx = textL.indexOf(q); if (textIdx !== -1) { score += 10; const s = Math.max(0, textIdx - 40); const e = Math.min(entry.text.length, textIdx + q.length + 70); snippet = (s > 0 ? '…' : '') + entry.text.slice(s, e).trim() + (e < entry.text.length ? '…' : ''); } if (score > 0) results.push({ ...entry, score, matchHeading, snippet }); } return results.sort((a, b) => b.score - a.score).slice(0, 8); } function highlightMatch(text, query) { const q = query.trim(); if (!q) return escapeHtml(text); const idx = text.toLowerCase().indexOf(q.toLowerCase()); if (idx === -1) return escapeHtml(text); return escapeHtml(text.slice(0, idx)) + `${escapeHtml(text.slice(idx, idx + q.length))}` + escapeHtml(text.slice(idx + q.length)); } function renderSearchResults(query) { const box = document.getElementById('search-results'); const kbd = document.getElementById('search-kbd'); if (!query.trim()) { box.classList.remove('is-open'); if (kbd) kbd.style.display = ''; searchFocusedIdx = -1; return; } if (kbd) kbd.style.display = 'none'; searchFocusedIdx = -1; const results = searchGuides(query); if (results.length === 0) { box.innerHTML = `
No results for "${escapeHtml(query)}"
`; box.classList.add('is-open'); return; } box.innerHTML = results.map((r, i) => { const titleHtml = highlightMatch(r.pageTitle, query); let metaHtml = escapeHtml(r.sectionTitle); if (r.matchHeading) metaHtml += ` · ${highlightMatch(r.matchHeading, query)}`; else if (r.snippet) metaHtml += ` · ${highlightMatch(r.snippet, query)}`; return `
${titleHtml}
${metaHtml}
`; }).join(''); box.querySelectorAll('.search-result').forEach(el => { el.addEventListener('mousedown', e => { e.preventDefault(); const heading = el.dataset.heading || null; closeSearch(); loadPage(el.dataset.file, heading); }); }); box.classList.add('is-open'); } function openSearchModal() { const modal = document.getElementById('search-modal'); const input = document.getElementById('search-input'); if (!modal || !input) return; modal.hidden = false; document.body.style.overflow = 'hidden'; ensureSearchIndex().then(() => { if (input.value.trim()) renderSearchResults(input.value); }); // focus next tick so modal animates in cleanly requestAnimationFrame(() => { input.focus(); input.select(); }); } function closeSearch() { const modal = document.getElementById('search-modal'); const input = document.getElementById('search-input'); const box = document.getElementById('search-results'); if (input) input.value = ''; if (box) box.classList.remove('is-open'); if (modal) modal.hidden = true; document.body.style.overflow = ''; searchFocusedIdx = -1; } function updateSearchFocus(items) { items.forEach((el, i) => el.classList.toggle('focused', i === searchFocusedIdx)); if (searchFocusedIdx >= 0 && items[searchFocusedIdx]) { items[searchFocusedIdx].scrollIntoView({ block: 'nearest' }); } } function initSearch() { const input = document.getElementById('search-input'); const box = document.getElementById('search-results'); if (!input || !box) return; input.addEventListener('input', () => { if (searchIndex.length) { renderSearchResults(input.value); return; } if (input.value.trim()) { box.innerHTML = '
Loading search…
'; box.classList.add('is-open'); } else { box.classList.remove('is-open'); } ensureSearchIndex().then(() => { if (input.value.trim()) renderSearchResults(input.value); }); }); input.addEventListener('keydown', e => { const items = [...box.querySelectorAll('.search-result')]; if (e.key === 'ArrowDown') { e.preventDefault(); searchFocusedIdx = Math.min(searchFocusedIdx + 1, items.length - 1); updateSearchFocus(items); } else if (e.key === 'ArrowUp') { e.preventDefault(); searchFocusedIdx = Math.max(searchFocusedIdx - 1, -1); updateSearchFocus(items); } else if (e.key === 'Enter') { e.preventDefault(); const target = searchFocusedIdx >= 0 ? items[searchFocusedIdx] : items.length === 1 ? items[0] : null; if (target) { closeSearch(); loadPage(target.dataset.file, target.dataset.heading || null); } } else if (e.key === 'Escape') { closeSearch(); input.blur(); } }); input.addEventListener('focus', () => { if (input.value.trim()) renderSearchResults(input.value); }); // Open modal from any navbar trigger button (desktop right + mobile left) document.querySelectorAll('.js-search-open').forEach(btn => btn.addEventListener('click', openSearchModal)); // Click backdrop to close const backdrop = document.getElementById('search-modal-backdrop'); if (backdrop) backdrop.addEventListener('click', closeSearch); // ⌘K / Ctrl+K opens; Esc closes (handled in input keydown above) document.addEventListener('keydown', e => { if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); openSearchModal(); } else if (e.key === 'Escape') { const modal = document.getElementById('search-modal'); if (modal && !modal.hidden) closeSearch(); } }); } /* ─── Boot ──────────────────────────────────────────────────────────────── */ let redirectMap = {}; function applyRedirect(route) { const key = normalizeRouteKey(decodeURIComponent((route || '').trim())); if (!key) return route; if (Object.prototype.hasOwnProperty.call(redirectMap, key)) { return redirectMap[key]; } return route; } async function init() { try { const res = await fetch(resolveContentUrl('content.json')); if (!res.ok) throw new Error(`content.json ${res.status}`); navData = await res.json(); } catch (e) { // Server-rendered documents stay readable without the nav manifest; only // report a failure when there is nothing on the page to fall back to. if (document.getElementById('article')?.children.length) return; showError('Could not load content.json. Check that the release bundle was uploaded intact and the base path is correct.', e.message); return; } // Optional: load redirect map for moved pages (old → new slug). try { const rRes = await fetch(resolveContentUrl('redirects.json')); if (rRes.ok) redirectMap = await rRes.json(); } catch { /* no redirects file is fine */ } buildFlatList(); buildLanding(); buildSidebar(); buildMobileDrawer(); initSearch(); const unlistedArticle = document.querySelector('article[data-unlisted-page]'); if (unlistedArticle) { const page = { file: 'hosted-beta.md', slug: 'hosted-beta', title: 'Hosted beta', sectionTitle: 'Closed beta', navTrail: ['Hosted beta'] }; currentFile = page.file; showArticleView(); insertMetaRow(unlistedArticle, page); decorateHeadings(unlistedArticle, page.file, page); buildToc(unlistedArticle, page.file, page); hideLoading(); const heading = getCurrentHeadingRoute(); if (heading) { const target = findHeadingTarget(unlistedArticle, heading); if (target) focusHeading(target); } return; } const pathRoute = getPathRoute(); const rawRoute = applyRedirect(pathRoute || getLegacyRoute()); const initialRoute = parseRoute(rawRoute); if (initialRoute.page && pathRoute) initialRoute.headingId = getCurrentHeadingRoute(); if (initialRoute.page) { const staticArticle = document.getElementById('article'); const useStaticArticle = Boolean(pathRoute && staticArticle?.children.length); loadPage(initialRoute.page.file, initialRoute.headingId, 'replace', { useStaticArticle }); } else { // Empty or unknown route -> landing showLanding(); } } /* ─── Landing cards + quick links ───────────────────────────────────────── */ function buildLanding() { const grid = document.getElementById('landing-cards'); // Interior documents have no homepage subtree at all. if (!grid) return; // The docs root ships the directory server-rendered from the same manifest. // Keep that DOM — the delegated click handlers already wire it up. if (grid.dataset.serverRendered === 'true') { renderLucideIcons(); return; } grid.innerHTML = ''; // Group sections by tier, preserving original indices so data-nav-section still works. const byTier = new Map(); navData.sections.forEach((section, i) => { const tier = getSectionKind(section); if (!byTier.has(tier)) byTier.set(tier, []); byTier.get(tier).push({ section, i }); }); const orderedTiers = [ ...TIER_ORDER.filter(t => byTier.has(t)), ...[...byTier.keys()].filter(t => !TIER_ORDER.includes(t)), ]; orderedTiers.forEach(tier => { const block = document.createElement('section'); block.className = 'landing-tier'; block.dataset.tier = tier; const n = byTier.get(tier).length; const cols = n <= 3 ? n : (n === 4 ? 2 : 3); block.innerHTML = `

${escapeHtml(tier)}

`; const cardsWrap = block.querySelector('.landing-tier-cards'); byTier.get(tier).forEach(({ section, i }) => { const firstPage = getFirstNavPage(section.pages); const pageCount = countNavPages(section.pages); const a = document.createElement('a'); a.className = 'card'; a.href = firstPage ? getPageUrl(firstPage) : '#'; a.dataset.navSection = i; const desc = section.desc || `${pageCount} page${pageCount === 1 ? '' : 's'} in ${section.title}.`; a.innerHTML = `
${sectionIconTag(section)}
${escapeHtml(section.title)}
${escapeHtml(desc)}
${pageCount} page${pageCount === 1 ? '' : 's'}${escapeHtml(getSectionKind(section))}
`; cardsWrap.appendChild(a); }); grid.appendChild(block); }); renderLucideIcons(); } function sidebarPagesHTML(nodes, level = 0) { return getNavChildren({ pages: nodes }).map(node => { if (isNavPage(node)) { return `${escapeHtml(node.title)}`; } const children = getNavChildren(node); const pageCount = countNavPages(children); if (!children.length) return ''; return `
${sidebarPagesHTML(children, level + 1)}
`; }).join(''); } /* ─── Sidebar (accordion) — used for desktop sidebar AND mobile drawer ──── */ function sidebarSectionsHTML() { const byTier = new Map(); navData.sections.forEach((section, si) => { const tier = getSectionKind(section); if (!byTier.has(tier)) byTier.set(tier, []); byTier.get(tier).push({ section, si }); }); const orderedTiers = [ ...TIER_ORDER.filter(t => byTier.has(t)), ...[...byTier.keys()].filter(t => !TIER_ORDER.includes(t)), ]; return orderedTiers.map(tier => { const header = `
${escapeHtml(tier)}
`; const sections = byTier.get(tier).map(({ section, si }) => { const pageCount = countNavPages(section.pages); return `
${sidebarPagesHTML(section.pages)}
`; }).join(''); return header + sections; }).join(''); } function wireSidebarContainer(container) { container.addEventListener('click', e => { const secBtn = e.target.closest('.sb-section-btn'); if (secBtn) { const sec = secBtn.parentElement; sec.dataset.open = sec.dataset.open === 'true' ? 'false' : 'true'; return; } const groupBtn = e.target.closest('.sb-group-btn'); if (groupBtn) { const group = groupBtn.parentElement; group.dataset.open = group.dataset.open === 'true' ? 'false' : 'true'; return; } const link = e.target.closest('.sb-link'); if (link) { e.preventDefault(); loadPage(link.dataset.file); closeDrawer(); } }); } function buildSidebar() { const container = document.getElementById('sb-sections'); // Interior routes ship the sidebar server-rendered from the same manifest, // so crawlers see all of it. Keep that DOM instead of replacing it with // byte-identical markup — re-rendering only costs a flash of layout. if (container.dataset.serverRendered !== 'true') { container.innerHTML = sidebarSectionsHTML(); } wireSidebarContainer(container); renderLucideIcons(); } function buildMobileDrawer() { const container = document.getElementById('drawer-sections'); container.innerHTML = sidebarSectionsHTML(); wireSidebarContainer(container); renderLucideIcons(); } /* ─── Flat list ─────────────────────────────────────────────────────────── */ function buildFlatList() { allPages = []; const slugCounts = new Map(); function visitNodes(nodes, section, groupTrail = []) { getNavChildren({ pages: nodes }).forEach(node => { if (isNavPage(node)) { const baseSlug = normalizeRouteKey(node.slug || derivePageSlug(node.file)); const seenCount = slugCounts.get(baseSlug) || 0; node.slug = seenCount === 0 ? baseSlug : `${baseSlug}-${seenCount + 1}`; slugCounts.set(baseSlug, seenCount + 1); allPages.push({ ...node, sectionTitle: section.title, navTrail: [section.title, ...groupTrail, node.title], }); return; } const children = getNavChildren(node); if (children.length) visitNodes(children, section, [...groupTrail, node.title].filter(Boolean)); }); } navData.sections.forEach(section => { visitNodes(section.pages, section); }); } /* ─── Load page ─────────────────────────────────────────────────────────── */ async function loadPage(file, targetHeading = null, historyMode = 'push', options = {}) { const page = allPages.find(candidate => candidate.file === file); if (document.querySelector('article[data-unlisted-page]') && page) { location.assign(targetHeading ? getPageHeadingUrl(page, targetHeading) : getPageUrl(page)); return; } currentFile = file; showArticleView(); setActiveState(file); let md; const article = document.getElementById('article'); const useStaticArticle = Boolean(options.useStaticArticle && article?.children.length); // Server-rendered content is already on screen; only a real client-side // transition has anything to wait for. if (!useStaticArticle) showLoading(); if (useStaticArticle) { currentMarkdown = ''; } else { try { md = await fetchMarkdown(file); currentMarkdown = md; updatePageSeo(page, md); article.innerHTML = await renderMarkdown(md); } catch (e) { showError(`Could not load: ${file}`, e.message); return; } } article.style.display = ''; // Insert sticky meta-row after the first h1 (before other post-processing so TOC can attach to it). insertMetaRow(article, page); decorateHeadings(article, file); postProcessCallouts(article); postProcessTabs(article); postProcessImages(article); postProcessTables(article); postProcessInternalLinks(article); decorateCodeBlocks(article); appendPageFeedback(article, page, file); renderPageNav(file); buildToc(article, file); updateBreadcrumb(page); hideLoading(); let resolvedHeadingId = null; if (targetHeading) { const match = findHeadingTarget(article, targetHeading); if (match) { resolvedHeadingId = match.id; focusHeading(match); } else { window.scrollTo(0, 0); } } else { window.scrollTo(0, 0); } if (page) { const nextUrl = resolvedHeadingId ? getPageHeadingUrl(page, resolvedHeadingId) : getPageUrl(page); if (historyMode === 'replace') history.replaceState(null, '', nextUrl); else if (historyMode === 'push') history.pushState(null, '', nextUrl); } } const DOCS_REPO_SLUG = 'paperclipai/paperclip-docs'; const DOCS_REPO_BRANCH = 'main'; function appendPageFeedback(article, page, file) { if (!page || !file) return; const repoPath = 'docs/' + normalizeDocPath(file).replace(/^(\.\.\/)+/, ''); const pageTitle = page.title || (article.querySelector('h1')?.textContent.trim() ?? 'Docs'); const docsUrl = location.origin + getPageUrl(page); const editUrl = `https://github.com/${DOCS_REPO_SLUG}/edit/${DOCS_REPO_BRANCH}/${repoPath}`; const issueUrl = `https://github.com/${DOCS_REPO_SLUG}/issues/new?` + new URLSearchParams({ template: '03-docs-feedback.yml', title: `[Docs]: ${pageTitle}`, 'docs_page': docsUrl, }).toString(); const editIcon = ''; const issueIcon = ''; const block = document.createElement('div'); block.className = 'page-feedback'; block.innerHTML = ` Help us improve this page ${editIcon}Suggest an edit ${issueIcon}Report an issue `; article.appendChild(block); } function insertMetaRow(article, page) { if (!page) return; const h1 = article.querySelector('h1'); if (!h1) return; if (article.querySelector('.meta-row')) return; const row = document.createElement('div'); row.className = 'meta-row'; row.innerHTML = `${escapeHtml(pageGroupLabel(page))}`; h1.after(row); row.appendChild(buildPageActions(page)); } function buildPageActions(page) { const COPY_SVG = ''; const LINK_SVG = ''; const CHECK_SVG = ''; const CARET_SVG = ''; const MD_SVG = ''; const EXT_SVG = ''; const wrap = document.createElement('div'); wrap.className = 'page-actions'; wrap.innerHTML = ` `; const copyBtn = wrap.querySelector('.pa-copy'); const caret = wrap.querySelector('.pa-caret'); const menu = wrap.querySelector('.pa-menu'); const closeMenu = () => { menu.classList.remove('is-open'); caret.setAttribute('aria-expanded', 'false'); }; const openMenu = () => { menu.classList.add('is-open'); caret.setAttribute('aria-expanded', 'true'); }; const flashCopied = (text) => { copyBtn.classList.add('is-copied'); copyBtn.innerHTML = `${CHECK_SVG}${text}`; setTimeout(() => { copyBtn.classList.remove('is-copied'); copyBtn.innerHTML = `${LINK_SVG}Copy Link`; }, 1600); }; const copyLink = async () => { try { await navigator.clipboard.writeText(location.href); flashCopied('Copied'); } catch (e) { console.error('Copy failed', e); } }; const copyMarkdown = async () => { try { const markdown = currentMarkdown || await fetchMarkdown(page.file); if (!currentMarkdown) currentMarkdown = markdown; await navigator.clipboard.writeText(markdown); flashCopied('Page copied'); } catch (e) { console.error('Copy failed', e); } }; const mdUrl = () => new URL(resolveContentUrl(page.file), location.href).href; const llmPrompt = () => `Read ${location.href} so I can ask questions about it.`; copyBtn.addEventListener('click', copyLink); caret.addEventListener('click', e => { e.stopPropagation(); menu.classList.contains('is-open') ? closeMenu() : openMenu(); }); menu.addEventListener('click', e => { const btn = e.target.closest('button[data-action]'); if (!btn) return; closeMenu(); switch (btn.dataset.action) { case 'copy-page': copyMarkdown(); break; case 'view-md': window.open(mdUrl(), '_blank', 'noopener'); break; case 'open-claude': window.open(`https://claude.ai/new?q=${encodeURIComponent(llmPrompt())}`, '_blank', 'noopener'); break; case 'open-chatgpt': window.open(`https://chatgpt.com/?hints=search&q=${encodeURIComponent(llmPrompt())}`, '_blank', 'noopener'); break; } }); document.addEventListener('click', e => { if (menu.classList.contains('is-open') && !wrap.contains(e.target)) closeMenu(); }); document.addEventListener('keydown', e => { if (e.key === 'Escape' && menu.classList.contains('is-open')) closeMenu(); }); return wrap; } function updateBreadcrumb(page) { const bc = document.getElementById('breadcrumb'); if (!bc) return; if (!page) { bc.innerHTML = ''; return; } const trail = pageTrail(page); bc.innerHTML = trail.map((crumb, index) => { const isCurrent = index === trail.length - 1; const crumbHtml = `${escapeHtml(crumb)}`; return index === 0 ? crumbHtml : `/${crumbHtml}`; }).join(''); } /* ─── Active state ──────────────────────────────────────────────────────── */ function setActiveState(file) { const page = allPages.find(p => p.file === file); if (!page) return; [document.getElementById('sb-sections'), document.getElementById('drawer-sections')].forEach(container => { if (!container) return; container.querySelectorAll('.sb-section').forEach(sec => { const isActive = sec.dataset.sectionTitle === page.sectionTitle; if (isActive) sec.dataset.open = 'true'; }); container.querySelectorAll('.sb-link').forEach(link => { const isActive = link.dataset.file === file; link.classList.toggle('active', isActive); if (isActive) { for (let parent = link.parentElement; parent; parent = parent.parentElement) { if (parent.classList?.contains('sb-section') || parent.classList?.contains('sb-group')) { parent.dataset.open = 'true'; } } } }); }); } /* ─── Markdown ──────────────────────────────────────────────────────────── */ function stripFrontmatter(md) { // Strip a leading YAML frontmatter block (---\n...\n---\n). The release // build strips this server-side, but in dev mode markdown is fetched // directly from disk and a leftover `---` would render as a horizontal // rule. Keep this tolerant: a malformed block (no closing fence) is left // alone so the author can see the broken markdown. if (typeof md !== 'string') return md; if (!md.startsWith('---\n') && !md.startsWith('---\r\n')) return md; const afterOpen = md.indexOf('\n') + 1; const rest = md.slice(afterOpen); const closeMatch = rest.match(/\r?\n---[ \t]*(\r?\n|$)/); if (!closeMatch) return md; return rest.slice(closeMatch.index + closeMatch[0].length).replace(/^\r?\n/, ''); } let skillAwareRenderer = null; // Mirror of the release build's code renderer (site/build-release.mjs): a fenced // block tagged `skill-source` renders as a standard markdown code block that also // advertises a downloadable filename via `data-skill-download`. Every other code // block is delegated to marked's default renderer, so existing snippets are // untouched. Keeping this in sync with the build keeps the Download control // available on SPA navigation as well as on the crawler-visible first render. function getSkillAwareRenderer(marked) { if (skillAwareRenderer) return skillAwareRenderer; const renderer = new marked.Renderer(); const defaultCode = renderer.code.bind(renderer); renderer.code = (code, infostring, escaped) => { const tokens = String(infostring || '').trim().split(/\s+/); if (tokens.includes('skill-source')) { return `
${escapeHtml(code)}\n
\n`; } return defaultCode(code, infostring, escaped); }; skillAwareRenderer = renderer; return renderer; } async function renderMarkdown(md) { const renderer = await ensureMarkdownRenderer(); renderer.setOptions({ gfm: true, breaks: false, renderer: getSkillAwareRenderer(renderer) }); md = stripFrontmatter(md); md = preprocessTabs(md); return sanitizeMarkdownHtml(renderer.parse(md)); } const ALLOWED_MARKDOWN_TAGS = new Set([ 'A', 'BLOCKQUOTE', 'BR', 'BUTTON', 'CODE', 'DEL', 'DETAILS', 'DIV', 'EM', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'HR', 'IMG', 'LI', 'OL', 'P', 'PRE', 'SPAN', 'STRONG', 'SUMMARY', 'TABLE', 'TBODY', 'TD', 'TH', 'THEAD', 'TR', 'UL', ]); const DROP_MARKDOWN_TAGS = new Set([ 'IFRAME', 'MATH', 'META', 'OBJECT', 'SCRIPT', 'STYLE', 'SVG', 'TEMPLATE', ]); const GLOBAL_MARKDOWN_ATTRS = new Set([ 'aria-label', 'aria-selected', 'class', 'colspan', 'data-panel', 'data-tab', 'id', 'role', 'rowspan', 'title', ]); const TAG_MARKDOWN_ATTRS = { A: new Set(['href']), BUTTON: new Set(['type']), DETAILS: new Set(['open']), CODE: new Set(['class', 'data-skill-download']), IMG: new Set(['alt', 'height', 'loading', 'src', 'title', 'width']), }; const SAFE_URL_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:']); const SAFE_IMAGE_DATA_URL = /^data:image\/(?:gif|jpe?g|png|webp);base64,[a-z0-9+/=]+$/i; function isSafeUrl(value, { image = false } = {}) { const normalized = String(value).replace(/[\u0000-\u001f\u007f\s]+/g, ''); if (!normalized) return false; if (image && SAFE_IMAGE_DATA_URL.test(normalized)) return true; if (normalized.startsWith('#')) return true; try { const parsed = new URL(normalized, window.location.href); return SAFE_URL_PROTOCOLS.has(parsed.protocol); } catch { return false; } } function sanitizeMarkdownElement(element) { if (!ALLOWED_MARKDOWN_TAGS.has(element.tagName)) { if (DROP_MARKDOWN_TAGS.has(element.tagName)) { element.remove(); return; } element.replaceWith(...element.childNodes); return; } for (const attr of [...element.attributes]) { const name = attr.name.toLowerCase(); const allowedForTag = TAG_MARKDOWN_ATTRS[element.tagName]?.has(name); if (name.startsWith('on') || (!GLOBAL_MARKDOWN_ATTRS.has(name) && !allowedForTag)) { element.removeAttribute(attr.name); continue; } if (name === 'href' && !isSafeUrl(attr.value)) { element.removeAttribute(attr.name); } if (name === 'src' && !isSafeUrl(attr.value, { image: element.tagName === 'IMG' })) { element.removeAttribute(attr.name); } } if (element.tagName === 'A' && element.hasAttribute('href')) { const href = element.getAttribute('href') || ''; if (/^(?:[a-z]+:)?\/\//i.test(href)) { element.setAttribute('target', '_blank'); element.setAttribute('rel', 'noopener noreferrer'); } } if (element.tagName === 'BUTTON') { element.setAttribute('type', 'button'); } } function sanitizeMarkdownHtml(html) { const document = new DOMParser().parseFromString(html, 'text/html'); const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT); const elements = []; while (walker.nextNode()) elements.push(walker.currentNode); elements.forEach(sanitizeMarkdownElement); return document.body.innerHTML; } function renderTabsBlock(labels, body) { const names = labels.split(',').map(s => s.trim()); let out = `
`; out += `
`; names.forEach((name, i) => { out += ``; }); out += `
`; const re = /([\s\S]*?)(?= blocks first, so // nested tab groups resolve correctly. Loops until no more pairs remain. const OPEN = ''; const MAX_ITERATIONS = 100; for (let i = 0; i < MAX_ITERATIONS; i++) { const closeIdx = md.indexOf(CLOSE); if (closeIdx === -1) break; // Find the nearest preceding ', openIdx); if (afterOpen === -1 || afterOpen > closeIdx) break; const labels = md.slice(openIdx + OPEN.length, afterOpen).trim(); const body = md.slice(afterOpen + 3, closeIdx); const replacement = renderTabsBlock(labels, body); md = md.slice(0, openIdx) + replacement + md.slice(closeIdx + CLOSE.length); } return md; } const CALLOUT_ICONS = { note: '', info: '', tip: '', warning: '', danger: '', }; function buildCalloutIconSvg(type) { const inner = CALLOUT_ICONS[type] || CALLOUT_ICONS.info; return ``; } function postProcessCallouts(root) { root.querySelectorAll('blockquote').forEach(bq => { const firstP = bq.querySelector('p'); if (!firstP) return; const text = firstP.innerHTML; const match = text.match(/^(Note|Info|Tip|Warning|Danger):<\/strong>\s*/i); if (!match) return; const type = match[1].toLowerCase(); const wrap = document.createElement('div'); wrap.className = `callout callout-${type}`; const icon = document.createElement('span'); icon.className = 'callout-icon'; icon.innerHTML = buildCalloutIconSvg(type); const body = document.createElement('div'); body.className = 'callout-body'; firstP.innerHTML = `${match[1]}: ` + text.slice(match[0].length); body.innerHTML = bq.innerHTML; wrap.appendChild(icon); wrap.appendChild(body); bq.replaceWith(wrap); }); } function postProcessTabs(root) { root.querySelectorAll('.tabs-container').forEach(c => { // Scope to DIRECT children so nested .tabs-container (e.g. "Get your API key" // inside an outer Desktop App/Terminal group) does not get its buttons or // panels toggled by the outer group's click handler. const btns = c.querySelectorAll(':scope > .tabs-bar > .tab-btn'); const panels = c.querySelectorAll(':scope > .tab-panel'); const isCodeTabs = panels.length > 0 && [...panels].every((panel) => { const meaningfulChildren = [...panel.children].filter((child) => { if (!(child instanceof HTMLElement)) return false; return child.tagName !== 'SCRIPT' && child.tagName !== 'STYLE'; }); return meaningfulChildren.length === 1 && meaningfulChildren[0].tagName === 'PRE'; }); if (isCodeTabs) c.classList.add('code-tabs'); btns.forEach(btn => { btn.addEventListener('click', () => { const name = btn.dataset.tab; btns.forEach(b => b.classList.toggle('active', b === btn)); panels.forEach(p => p.classList.toggle('active', p.dataset.panel === name)); }); }); }); } const RESPONSIVE_SCREENSHOT_VARIANTS = new Map([ ['dashboard/dashboard-overview.png', { width: 2880, height: 1800, variantWidth: 900 }], ]); function getScreenshotVariantConfig(src) { const match = String(src).match(/(?:screenshots|images)\/(?:light|dark)\/(.+)$/); if (!match) return null; return RESPONSIVE_SCREENSHOT_VARIANTS.get(match[1]) || null; } function applyScreenshotSource(img, rawSrc) { const resolved = resolveScreenshotSrc(rawSrc); if (resolved !== rawSrc) { img.dataset.screenshot = rawSrc; } img.src = resolved; const variantConfig = getScreenshotVariantConfig(resolved) || getScreenshotVariantConfig(rawSrc); if (!variantConfig) return; const optimizedSrc = resolved.replace(/\.png(?:\?.*)?$/i, '-900.webp'); img.width = variantConfig.width; img.height = variantConfig.height; img.sizes = '(max-width: 820px) calc(100vw - 48px), 820px'; img.srcset = `${optimizedSrc} ${variantConfig.variantWidth}w, ${resolved} ${variantConfig.width}w`; img.classList.add('responsive-screenshot'); img.style.aspectRatio = `${variantConfig.width} / ${variantConfig.height}`; } function postProcessImages(root) { [...root.querySelectorAll('img')].forEach((img, index) => { // Remap ../images/ paths to screenshots/{theme}/ and store original for theme swaps const rawSrc = img.getAttribute('src') || img.src; applyScreenshotSource(img, rawSrc); img.decoding = 'async'; img.loading = index === 0 ? 'eager' : 'lazy'; img.fetchPriority = index === 0 ? 'high' : 'auto'; img.addEventListener('error', () => { const ph = document.createElement('div'); ph.className = 'img-placeholder'; ph.innerHTML = `Screenshot: ${escapeHtml(img.alt || img.src)}`; img.replaceWith(ph); }); }); } // The connector catalog is one logical table split across category headings. // Tagging each of those tables lets CSS give them identical column widths, so // a reader scanning down the page reads one set of columns rather than eleven // differently proportioned ones. Detection is by header row, not by page, so // the rule follows the content if it moves. const CATALOG_TABLE_HEADINGS = ['connector', 'what you can do', 'connection methods']; const METHODS_COLUMN = 2; // Authors write a connector's methods as "A · B". In a narrow column that // wraps wherever the words run out ("Connect with Paperclip · Your / own OAuth // app"), which reads as one broken phrase instead of two methods. Giving each // method its own line is the fix that cannot overflow: nowrap would push a // long method past the column at intermediate widths. function splitMethodList(cell) { if (cell.children.length > 0 || cell.dataset.methodsSplit === 'true') return; const parts = (cell.textContent || '').split('·').map(part => part.trim()).filter(Boolean); if (parts.length < 2) return; cell.dataset.methodsSplit = 'true'; cell.textContent = ''; for (const part of parts) { const item = document.createElement('span'); item.className = 'method-item'; item.textContent = part; cell.appendChild(item); } } function isCatalogTable(headings) { return headings.length === CATALOG_TABLE_HEADINGS.length && headings.every((heading, index) => heading === CATALOG_TABLE_HEADINGS[index]); } // Below the stacking breakpoint these tables render as blocks, which drops the // implicit table semantics a browser infers from display: table. Stating the // roles keeps the structure in the accessibility tree at every width, so the // visually hidden header row is still what a screen reader announces per cell. function applyExplicitTableRoles(table) { table.setAttribute('role', 'table'); table.querySelectorAll('thead, tbody, tfoot').forEach(group => group.setAttribute('role', 'rowgroup')); table.querySelectorAll('tr').forEach(row => row.setAttribute('role', 'row')); table.querySelectorAll('thead th').forEach(cell => cell.setAttribute('role', 'columnheader')); table.querySelectorAll('tbody td').forEach(cell => cell.setAttribute('role', 'cell')); } function postProcessTables(root) { root.querySelectorAll('table').forEach(table => { const headingLabels = [...table.querySelectorAll('thead th')] .map(cell => (cell.textContent || '').trim()); if (isCatalogTable(headingLabels.map(label => label.toLowerCase()))) { table.classList.add('catalog-table'); applyExplicitTableRoles(table); // Stacked cells lose their column, so each one carries its own label. table.querySelectorAll('tbody tr').forEach(row => { [...row.children].forEach((cell, index) => { if (headingLabels[index]) cell.setAttribute('data-label', headingLabels[index]); if (index === METHODS_COLUMN) splitMethodList(cell); }); }); } if (table.parentElement && table.parentElement.classList.contains('table-wrap')) return; const wrap = document.createElement('div'); wrap.className = 'table-wrap'; if (table.classList.contains('catalog-table')) wrap.classList.add('table-wrap-catalog'); table.parentNode.insertBefore(wrap, table); wrap.appendChild(table); }); } function findPageByRoutePath(routePath) { const basePath = APP_BASE_URL.pathname.replace(/\/$/, ''); if (basePath && !routePath.startsWith(`${basePath}/`) && routePath !== `${basePath}/`) return null; const slug = normalizeRouteKey(routePath.slice(basePath.length)); if (!slug) return null; return allPages.find(candidate => candidate.slug === slug) || null; } function postProcessInternalLinks(root) { root.querySelectorAll('a[href]').forEach(a => { const href = a.getAttribute('href'); if (!href) return; // External links → new tab if (/^https?:\/\//i.test(href)) { const sameOrigin = (() => { try { return new URL(href).origin === window.location.origin; } catch { return false; } })(); if (!sameOrigin) { a.setAttribute('target', '_blank'); a.setAttribute('rel', 'noopener noreferrer'); } return; } if (href.startsWith('#')) { const page = allPages.find(candidate => candidate.file === currentFile); const headingId = normalizeRouteKey(href.slice(1)); if (page && headingId) { a.href = getPageHeadingUrl(page, headingId); a.addEventListener('click', e => { e.preventDefault(); const heading = document.getElementById(headingId); history.pushState(null, '', getPageHeadingUrl(page, headingId)); if (heading) focusHeading(heading); }); } return; } const [docHref, headingHref] = href.split('#'); // Static builds already emit canonical route hrefs. Route them through the // client so an in-article link is still a soft transition, not a reload. if (docHref && docHref.startsWith('/')) { const routePage = findPageByRoutePath(docHref); if (routePage) { a.addEventListener('click', e => { if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return; e.preventDefault(); loadPage(routePage.file, headingHref || null); }); } return; } // Legacy relative markdown links, still present when the SPA renders // markdown it fetched at runtime. if (docHref && docHref.endsWith('.md')) { const targetFile = resolveDocLinkTarget(docHref, currentFile); const targetPage = allPages.find(candidate => candidate.file === targetFile); if (targetPage) { a.href = headingHref ? getPageHeadingUrl(targetPage, headingHref) : getPageUrl(targetPage); } a.addEventListener('click', e => { if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return; e.preventDefault(); loadPage(targetFile, headingHref || null); }); } }); } /* ─── Table of contents (desktop rail + compact dropdown) ───────────────── */ function resetToc() { if (tocObserver) { tocObserver.disconnect(); tocObserver = null; } if (tocScrollHandler) { window.removeEventListener('scroll', tocScrollHandler); tocScrollHandler = null; } if (tocResizeHandler) { window.removeEventListener('resize', tocResizeHandler); tocResizeHandler = null; } if (tocDocumentClickHandler) { document.removeEventListener('click', tocDocumentClickHandler); tocDocumentClickHandler = null; } if (tocKeydownHandler) { document.removeEventListener('keydown', tocKeydownHandler); tocKeydownHandler = null; } document.querySelectorAll('.toc-wrap').forEach(el => el.remove()); const rail = document.getElementById('toc-rail'); const railLinks = document.getElementById('toc-rail-links'); if (railLinks) railLinks.innerHTML = ''; if (rail) rail.classList.add('is-empty'); } function headingLabel(heading) { const clone = heading.cloneNode(true); clone.querySelectorAll('.heading-anchor').forEach(anchor => anchor.remove()); return clone.textContent.trim(); } function buildToc(article, file, unlistedPage = null) { resetToc(); const metaRow = article.querySelector('.meta-row'); const rail = document.getElementById('toc-rail'); const railLinks = document.getElementById('toc-rail-links'); if (!metaRow || !rail || !railLinks) return; const headings = [...article.querySelectorAll('h2, h3')]; if (headings.length < 2) return; const page = unlistedPage || allPages.find(candidate => candidate.file === file); const CHEVRON_SVG = ''; const TOC_SVG = ''; function makeTocLink(heading, onClick) { const a = document.createElement('a'); a.className = `toc-link ${heading.tagName === 'H3' ? 'level-3' : 'level-2'}`; a.dataset.headingId = heading.id; a.textContent = headingLabel(heading); a.href = page ? getPageHeadingUrl(page, heading.id) : `#${heading.id}`; a.addEventListener('click', e => { e.preventDefault(); if (page) history.pushState(null, '', getPageHeadingUrl(page, heading.id)); focusHeading(heading); if (onClick) onClick(); }); return a; } const wrap = document.createElement('div'); wrap.className = 'toc-wrap'; wrap.innerHTML = ` `; const panel = wrap.querySelector('.toc-panel'); const toggle = wrap.querySelector('.toc-toggle'); const closePanel = () => { panel.classList.remove('is-open'); toggle.setAttribute('aria-expanded', 'false'); }; const openPanel = () => { panel.classList.add('is-open'); toggle.setAttribute('aria-expanded', 'true'); }; headings.forEach(h => { panel.appendChild(makeTocLink(h, closePanel)); railLinks.appendChild(makeTocLink(h)); }); metaRow.appendChild(wrap); rail.classList.remove('is-empty'); toggle.addEventListener('click', e => { e.stopPropagation(); panel.classList.contains('is-open') ? closePanel() : openPanel(); }); tocDocumentClickHandler = e => { if (panel.classList.contains('is-open') && !wrap.contains(e.target)) closePanel(); }; tocKeydownHandler = e => { if (e.key === 'Escape' && panel.classList.contains('is-open')) closePanel(); }; document.addEventListener('click', tocDocumentClickHandler); document.addEventListener('keydown', tocKeydownHandler); const links = [...document.querySelectorAll('.toc-link')]; const setActiveLink = id => { links.forEach(link => link.classList.toggle('active', link.dataset.headingId === id)); }; const getActiveHeading = () => { const offset = 136; const bottomSlack = 4; if (window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - bottomSlack) { return headings[headings.length - 1]; } let active = headings[0]; for (const heading of headings) { if (heading.getBoundingClientRect().top <= offset) active = heading; else break; } return active; }; let tocFrame = 0; const updateActive = () => { tocFrame = 0; setActiveLink(getActiveHeading().id); }; const scheduleActiveUpdate = () => { if (tocFrame) return; tocFrame = window.requestAnimationFrame(updateActive); }; tocScrollHandler = scheduleActiveUpdate; tocResizeHandler = scheduleActiveUpdate; window.addEventListener('scroll', tocScrollHandler, { passive: true }); window.addEventListener('resize', tocResizeHandler); tocObserver = new IntersectionObserver(scheduleActiveUpdate, { rootMargin: '-136px 0px -62% 0px', threshold: [0, 1], }); headings.forEach(h => tocObserver.observe(h)); updateActive(); } /* ─── Prev/next ─────────────────────────────────────────────────────────── */ function renderPageNav(file) { const nav = document.getElementById('page-nav'); const idx = allPages.findIndex(p => p.file === file); if (idx === -1) { nav.style.display = 'none'; return; } const prev = allPages[idx - 1]; const next = allPages[idx + 1]; nav.innerHTML = ''; if (prev) { const link = document.createElement('a'); link.className = 'page-nav-btn prev'; link.href = getPageUrl(prev); link.innerHTML = `← Previous${escapeHtml(prev.title)}`; link.addEventListener('click', e => { e.preventDefault(); loadPage(prev.file); }); nav.appendChild(link); } else { nav.appendChild(Object.assign(document.createElement('div'), { className: 'page-nav-spacer' })); } if (next) { const link = document.createElement('a'); link.className = 'page-nav-btn next'; link.href = getPageUrl(next); link.innerHTML = `Next →${escapeHtml(next.title)}`; link.addEventListener('click', e => { e.preventDefault(); loadPage(next.file); }); nav.appendChild(link); } nav.style.display = (prev || next) ? 'flex' : 'none'; } /* ─── Helpers ───────────────────────────────────────────────────────────── */ /* Runtime status is built on demand so a served document never ships loading or error copy it cannot justify. Only real client-side transitions fill it in. */ function renderRuntimeStatus(state, message, detail = '') { const mount = document.getElementById('runtime-status'); if (!mount) return; mount.textContent = ''; mount.dataset.state = state; if (state === 'loading') { const spinner = document.createElement('div'); spinner.className = 'spinner'; mount.appendChild(spinner); } const messageEl = document.createElement('span'); messageEl.className = 'runtime-status-message'; messageEl.textContent = message; mount.appendChild(messageEl); if (detail) { const detailEl = document.createElement('span'); detailEl.className = 'runtime-status-detail'; detailEl.textContent = detail; mount.appendChild(detailEl); } mount.hidden = false; } function clearRuntimeStatus() { const mount = document.getElementById('runtime-status'); if (!mount) return; mount.hidden = true; delete mount.dataset.state; mount.textContent = ''; } function showLoading() { resetToc(); renderRuntimeStatus('loading', 'Loading…'); document.getElementById('article').style.display = 'none'; document.getElementById('page-nav').style.display = 'none'; } function hideLoading() { clearRuntimeStatus(); } function showError(msg, detail = '') { // Error state lives inside article-view; make sure the right view is showing. showArticleView(); resetToc(); document.getElementById('article').style.display = 'none'; document.getElementById('page-nav').style.display = 'none'; renderRuntimeStatus('error', msg, detail); } function escapeHtml(s) { return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } function escapeAttr(s) { return String(s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } window.addEventListener('hashchange', () => { if (getPathRoute() && !location.hash.startsWith('#/')) return; const route = parseRoute(applyRedirect(location.hash.slice(1))); if (route.page) { loadPage(route.page.file, route.headingId, 'replace'); } else { const raw = normalizeRouteKey(decodeURIComponent(location.hash.slice(1).trim())); if (!raw) showLanding(); } }); window.addEventListener('popstate', () => { const route = parseRoute(applyRedirect(getCurrentRoute())); if (route.page && getPathRoute()) route.headingId = getCurrentHeadingRoute(); if (route.page) { loadPage(route.page.file, route.headingId, 'replace'); return; } const raw = normalizeRouteKey(decodeURIComponent((getCurrentRoute() || '').trim())); if (!raw) showLanding(); }); init();