Last active
March 1, 2026 05:45
-
-
Save FireController1847/0b442ec9b19b55b9e0332b9f9b2a7b9f to your computer and use it in GitHub Desktop.
GitHub “Print-friendly Markdown” helper
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * ============================================================================= | |
| * GitHub Markdown → Print Preparation Utility | |
| * ============================================================================= | |
| * | |
| * What This Is | |
| * ------------ | |
| * A console-executable DOM transformation script that: | |
| * | |
| * • Isolates the rendered Markdown section of a GitHub file page | |
| * • Removes surrounding GitHub UI chrome | |
| * • Optimizes layout for printing or PDF export | |
| * • Prevents awkward page splits (headings, tables, code blocks) | |
| * • Auto-sizes table columns while preserving full-width layout | |
| * • Applies anti-orphan rules to headings | |
| * | |
| * The result: a clean, professional, pagination-safe print output. | |
| * | |
| * ----------------------------------------------------------------------------- | |
| * How To Use | |
| * ----------------------------------------------------------------------------- | |
| * | |
| * 1. Open any GitHub file page that displays rendered Markdown. | |
| * 2. Open your browser DevTools console. | |
| * - Chrome / Edge: F12 → Console | |
| * - Firefox: F12 → Console | |
| * 3. Paste this entire script into the console. | |
| * 4. Press Enter. | |
| * 5. Open the browser Print dialog (Ctrl/Cmd + P). | |
| * - Enable "Print backgrounds" for best visual fidelity. | |
| * 6. Print or Save as PDF. | |
| * | |
| * ----------------------------------------------------------------------------- | |
| * What It Modifies | |
| * ----------------------------------------------------------------------------- | |
| * | |
| * • Clears the existing page body and replaces it with cloned markdown. | |
| * • Applies inline print styles for pagination control. | |
| * • Does NOT modify GitHub permanently (page refresh restores everything). | |
| * | |
| * ----------------------------------------------------------------------------- | |
| * Safety Notes | |
| * ----------------------------------------------------------------------------- | |
| * | |
| * • This script runs entirely client-side. | |
| * • No network requests are made. | |
| * • No persistent changes occur. | |
| * • Refreshing the page restores the original DOM. | |
| * | |
| * ----------------------------------------------------------------------------- | |
| * Intended Use | |
| * ----------------------------------------------------------------------------- | |
| * | |
| * Designed for: | |
| * - Printing technical documentation | |
| * - Exporting clean PDFs of READMEs | |
| * - Generating printable internal documentation | |
| * - Archival documentation snapshots | |
| * | |
| * ----------------------------------------------------------------------------- | |
| * Development Notes (Vibe-Coded Edition) | |
| * ----------------------------------------------------------------------------- | |
| * | |
| * This script was vibe-coded and built collaboratively with ChatGPT. | |
| * | |
| * It prioritizes practical effectiveness over architectural purity. | |
| * It works well. It solves the problem. It ships. | |
| * | |
| * It is not intended to represent production-level engineering rigor, | |
| * enterprise design patterns, or the author's formal coding standards. | |
| * | |
| * Think of it as: | |
| * High-leverage utility > pristine abstraction. | |
| * | |
| * If it prints beautifully, it has fulfilled its purpose. | |
| * | |
| * ============================================================================= | |
| */ | |
| (() => { | |
| const LOG_NS = "[gh-print-prep]"; | |
| const now = () => (performance?.now?.() ?? Date.now()).toFixed(1); | |
| const log = (...a) => console.log(`${LOG_NS} ${now()}ms`, ...a); | |
| const info = (...a) => console.info(`${LOG_NS} ${now()}ms`, ...a); | |
| const warn = (...a) => console.warn(`${LOG_NS} ${now()}ms`, ...a); | |
| const dbg = (...a) => console.debug(`${LOG_NS} ${now()}ms`, ...a); | |
| const err = (...a) => console.error(`${LOG_NS} ${now()}ms`, ...a); | |
| const group = (label, fn) => { | |
| console.groupCollapsed(`${LOG_NS} ${now()}ms ${label}`); | |
| try { | |
| return fn(); | |
| } finally { | |
| console.groupEnd(); | |
| } | |
| }; | |
| // Tip: In Chrome/Edge you can toggle verbose debug output in the console filters. | |
| info("Booting… preparing to isolate GitHub-rendered markdown and apply print rules."); | |
| // =========================================================================== | |
| // helpers | |
| // =========================================================================== | |
| const qs = (s, r = document) => r.querySelector(s); | |
| const qsa = (s, r = document) => Array.from(r.querySelectorAll(s)); | |
| const isEl = (n) => n && n.nodeType === 1; | |
| const setStyle = (el, styleObj, label = "style") => { | |
| if (!el) return; | |
| Object.assign(el.style, styleObj); | |
| dbg(`Applied ${label}:`, styleObj, "→", el); | |
| }; | |
| const avoidInside = (el) => { | |
| if (!el) return; | |
| el.style.pageBreakInside = "avoid"; | |
| el.style.breakInside = "avoid"; | |
| dbg("Applied break-inside: avoid →", el); | |
| }; | |
| const avoidAfter = (el) => { | |
| if (!el) return; | |
| el.style.pageBreakAfter = "avoid"; | |
| el.style.breakAfter = "avoid"; | |
| dbg("Applied break-after: avoid →", el); | |
| }; | |
| const avoidBefore = (el) => { | |
| if (!el) return; | |
| el.style.pageBreakBefore = "avoid"; | |
| el.style.breakBefore = "avoid"; | |
| dbg("Applied break-before: avoid →", el); | |
| }; | |
| const nextMeaningfulEl = (el) => { | |
| let n = el && el.nextSibling; | |
| while (n) { | |
| if (isEl(n)) return n; | |
| n = n.nextSibling; | |
| } | |
| return null; | |
| }; | |
| const prevMeaningfulEl = (el) => { | |
| let n = el && el.previousSibling; | |
| while (n) { | |
| if (isEl(n)) return n; | |
| n = n.previousSibling; | |
| } | |
| return null; | |
| }; | |
| const containsTable = (el) => | |
| !!(el && (el.tagName?.toLowerCase() === "table" || el.querySelector?.("table"))); | |
| // =========================================================================== | |
| // 1) isolate GitHub rendered markdown section | |
| // =========================================================================== | |
| group("Step 1 — Locate rendered markdown container", () => { | |
| dbg("Searching for markdown section candidates…"); | |
| const selectors = [ | |
| 'section[class*="BlobContent-module__blobContentSectionMarkdown__"]', | |
| 'section[class*="BlobContent-module__blobContentSection__"]', | |
| '[class*="DirectoryRichtextContent-module__SharedMarkdownContent__"]', | |
| ]; | |
| selectors.forEach((sel) => dbg("Candidate selector:", sel)); | |
| const section = | |
| qs(selectors[0]) || | |
| qs(selectors[1]) || | |
| qs(selectors[2]); | |
| if (!section) { | |
| err("Markdown section not found. Selectors tried:", selectors); | |
| return; | |
| } | |
| info("Markdown section found:", section); | |
| group("Isolate DOM — clone markdown and wipe surrounding page chrome", () => { | |
| const clone = section.cloneNode(true); | |
| dbg("Cloned markdown section:", clone); | |
| // Capture metadata BEFORE we wipe GitHub chrome | |
| const captureMeta = () => { | |
| // Commit hash (prefer visible 7-char link; fallback to href) | |
| let hash7 = null; | |
| const commitLink = qs('a[href*="/commit/"]'); | |
| if (commitLink) { | |
| const txt = (commitLink.textContent || "").trim(); | |
| if (/^[0-9a-f]{7}$/i.test(txt)) hash7 = txt; | |
| if (!hash7) { | |
| const href = commitLink.getAttribute("href") || ""; | |
| const m = href.match(/\/commit\/([0-9a-f]{7,40})/i); | |
| if (m) hash7 = m[1].slice(0, 7); | |
| } | |
| } | |
| // Commit datetime (GitHub uses <relative-time datetime="...">) | |
| let iso = null; | |
| let title = null; | |
| let display = null; | |
| const rt = qs("relative-time[datetime]"); | |
| if (rt) { | |
| iso = rt.getAttribute("datetime"); | |
| title = rt.getAttribute("title") || null; | |
| display = (rt.textContent || "").trim() || null; | |
| } | |
| // Fallback: some pages use <time datetime="..."> | |
| if (!iso) { | |
| const t = qs('time[datetime]'); | |
| if (t) { | |
| iso = t.getAttribute("datetime"); | |
| title = t.getAttribute("title") || null; | |
| display = (t.textContent || "").trim() || null; | |
| } | |
| } | |
| // Filename from URL (best), fallback to document.title | |
| const seg = (location.pathname || "").split("/").filter(Boolean); | |
| const mode = seg[2] || ""; | |
| const isRepoRoot = seg.length === 2; // /owner/repo | |
| const isFileRoute = mode === "blob" || mode === "tree" || mode === "blame" || mode === "raw"; | |
| const filePath = isFileRoute ? seg.slice(4).join("/") : seg.slice(2).join("/"); | |
| const fileParts = filePath ? filePath.split("/").filter(Boolean) : []; | |
| const filename = | |
| isRepoRoot ? "README.md" | |
| : (fileParts.length ? fileParts[fileParts.length - 1] : null) || document.title || null; | |
| if (isRepoRoot) { | |
| // breadcrumb should be repo › README.md | |
| // fileParts should include README.md so Step 6 renders it | |
| return { hash7, iso, title, display, filePath: "", fileParts: ["README.md"], filename }; | |
| } | |
| return { hash7, iso, title, display, filePath, fileParts, filename }; | |
| }; | |
| const capturedMeta = captureMeta(); | |
| dbg("Captured meta (pre-wipe):", capturedMeta); | |
| dbg("Clearing document.body.innerHTML (this will remove GitHub chrome)…"); | |
| document.body.innerHTML = ""; | |
| setStyle(document.documentElement, { margin: "0", padding: "0" }, "documentElement base"); | |
| setStyle(document.body, { margin: "0", padding: "32px", maxWidth: "none" }, "body base"); | |
| setStyle(clone, { maxWidth: "none", width: "auto", margin: "0 auto" }, "clone container"); | |
| document.body.appendChild(clone); | |
| info("Clone appended. Page is now markdown-only for printing."); | |
| group("Print color preservation", () => { | |
| // Preserve colors as-authored (best with "Print backgrounds" enabled) | |
| const setPrintColor = (el) => { | |
| el.style.setProperty("-webkit-print-color-adjust", "exact"); | |
| el.style.setProperty("print-color-adjust", "exact"); | |
| dbg("Set print-color-adjust to exact for:", el); | |
| }; | |
| setPrintColor(document.documentElement); | |
| setPrintColor(document.body); | |
| info("Print color adjustment configured (exact)."); | |
| }); | |
| // Make section available to later steps by stashing on window (no global vars elsewhere). | |
| window.__GH_PRINT_PREP__ = { cloneRoot: clone, meta: capturedMeta }; | |
| dbg("Stashed cloneRoot on window.__GH_PRINT_PREP__ for later steps."); | |
| }); | |
| }); | |
| // If isolation failed, bail (window stash won't exist). | |
| if (!window.__GH_PRINT_PREP__?.cloneRoot) { | |
| warn("Aborting: markdown isolation did not complete successfully."); | |
| return; | |
| } | |
| const root = window.__GH_PRINT_PREP__.cloneRoot; | |
| // =========================================================================== | |
| // 2) group GitHub heading blocks with the following table wrapper | |
| // =========================================================================== | |
| group("Step 2 — Keep headings attached to their tables", () => { | |
| let grouped = 0; | |
| dbg("Collecting heading blocks (.markdown-heading preferred; fallback to h1..h6)..."); | |
| const headingBlocks = qsa(".markdown-heading", root); | |
| if (headingBlocks.length === 0) { | |
| dbg("No .markdown-heading found; falling back to raw heading tags."); | |
| qsa("h1,h2,h3,h4,h5,h6", root).forEach((h) => headingBlocks.push(h)); | |
| } | |
| info(`Heading blocks detected: ${headingBlocks.length}`); | |
| for (const hb of headingBlocks) { | |
| const next = nextMeaningfulEl(hb); | |
| if (!next) { | |
| dbg("Heading has no meaningful next sibling; skipping:", hb); | |
| continue; | |
| } | |
| const isTableWrapper = | |
| next.classList?.contains("markdown-accessibility-table") || containsTable(next); | |
| if (!isTableWrapper) { | |
| dbg("Next block is not table-ish; skipping wrap for:", hb, "→ next:", next); | |
| continue; | |
| } | |
| dbg("Wrapping heading with its table-ish next block:", { heading: hb, next }); | |
| const wrapper = document.createElement("div"); | |
| wrapper.className = "keep-heading-with-table"; | |
| avoidInside(wrapper); | |
| hb.parentNode.insertBefore(wrapper, hb); | |
| wrapper.appendChild(hb); | |
| wrapper.appendChild(next); | |
| grouped++; | |
| dbg("Wrapped heading+table pair. Total so far:", grouped); | |
| } | |
| window.__GH_PRINT_PREP__.groupedHeadingTablePairs = grouped; | |
| info(`Completed heading+table grouping. Total wrapped pairs: ${grouped}`); | |
| }); | |
| // =========================================================================== | |
| // 2b) keep heading block with the immediate following content block (usually <p>) | |
| // =========================================================================== | |
| group("Step 2b — Keep headings attached to their immediate intro block", () => { | |
| let groupedIntro = 0; | |
| const introJoinableTags = new Set(["p", "ul", "ol", "pre", "blockquote", "div"]); | |
| const headings = qsa(".markdown-heading", root); | |
| info(`Scanning ${headings.length} markdown-heading blocks for intro pairing…`); | |
| headings.forEach((hb, i) => { | |
| if (hb.closest?.(".keep-heading-with-table")) { | |
| dbg(`(${i}) Already in keep-heading-with-table; skipping.`, hb); | |
| return; | |
| } | |
| if (hb.closest?.(".keep-heading-with-next")) { | |
| dbg(`(${i}) Already in keep-heading-with-next; skipping.`, hb); | |
| return; | |
| } | |
| const next = nextMeaningfulEl(hb); | |
| if (!next) { | |
| dbg(`(${i}) No next meaningful element; skipping.`, hb); | |
| return; | |
| } | |
| const nextIsTableish = | |
| next.classList?.contains("markdown-accessibility-table") || | |
| next.tagName?.toLowerCase() === "table" || | |
| next.querySelector?.("table"); | |
| if (nextIsTableish) { | |
| dbg(`(${i}) Next is table-ish; Step 2 handles this; skipping.`, { hb, next }); | |
| return; | |
| } | |
| // Leave "heading -> heading" to be handled by 2c (recursive prepend) | |
| if (next.classList?.contains("markdown-heading")) { | |
| dbg(`(${i}) Heading followed by heading; leaving for Step 2c.`, { hb, next }); | |
| return; | |
| } | |
| const tag = next.tagName?.toLowerCase(); | |
| if (!tag || !introJoinableTags.has(tag)) { | |
| dbg(`(${i}) Next tag not joinable (${tag}); skipping.`, { hb, next }); | |
| return; | |
| } | |
| if (tag === "div") { | |
| const looksLikeContainer = | |
| next.querySelector?.("p,ul,ol,pre,table,blockquote,div") && | |
| next.querySelectorAll?.("*").length > 20; | |
| if (looksLikeContainer) { | |
| dbg(`(${i}) Next div looks like a large container; skipping to avoid page jumps.`, next); | |
| return; | |
| } | |
| } | |
| dbg(`(${i}) Wrapping heading with ONE immediate intro block.`, { hb, next, tag }); | |
| const wrapper = document.createElement("div"); | |
| wrapper.className = "keep-heading-with-next"; | |
| avoidInside(wrapper); | |
| hb.parentNode.insertBefore(wrapper, hb); | |
| wrapper.appendChild(hb); | |
| wrapper.appendChild(next); | |
| groupedIntro++; | |
| dbg(`(${i}) Wrapped heading+next. Total intro wraps so far:`, groupedIntro); | |
| }); | |
| window.__GH_PRINT_PREP__.groupedHeadingNextPairs = groupedIntro; | |
| info(`Completed heading+next grouping. Total wrapped intro pairs: ${groupedIntro}`); | |
| }); | |
| // =========================================================================== | |
| // 2c) prepend ALL consecutive preceding headings into each keep-wrapper | |
| // =========================================================================== | |
| group("Step 2c — Merge consecutive heading chains into the nearest keep-wrapper", () => { | |
| let groupedRecursiveHeadings = 0; | |
| const keepWrappers = qsa(".keep-heading-with-next, .keep-heading-with-table", root); | |
| info(`Found keep-wrappers: ${keepWrappers.length}`); | |
| keepWrappers.forEach((wrap, idx) => { | |
| dbg(`Wrapper[${idx}] scanning previous siblings for heading chains…`, wrap); | |
| const collected = []; | |
| let prev = prevMeaningfulEl(wrap); | |
| while (prev && prev.classList?.contains("markdown-heading")) { | |
| // Don't steal headings that are already inside some keep wrapper | |
| if (prev.closest?.(".keep-heading-with-next, .keep-heading-with-table")) { | |
| dbg("Encountered a heading already owned by another wrapper; stopping chain capture.", prev); | |
| break; | |
| } | |
| collected.push(prev); | |
| dbg("Captured preceding heading:", prev); | |
| prev = prevMeaningfulEl(prev); | |
| } | |
| if (collected.length === 0) { | |
| dbg(`Wrapper[${idx}] no preceding heading chain found.`); | |
| return; | |
| } | |
| dbg(`Wrapper[${idx}] captured ${collected.length} headings. Reinserting in document order…`); | |
| const frag = document.createDocumentFragment(); | |
| for (const h of collected.reverse()) frag.appendChild(h); | |
| wrap.insertBefore(frag, wrap.firstChild); | |
| groupedRecursiveHeadings += collected.length; | |
| // Ensure wrapper still has anti-split (in case anything modified it) | |
| avoidInside(wrap); | |
| info(`Wrapper[${idx}] prepended ${collected.length} headings. Running total: ${groupedRecursiveHeadings}`); | |
| }); | |
| window.__GH_PRINT_PREP__.prependedHeadingCount = groupedRecursiveHeadings; | |
| info(`Completed heading-chain prepends. Total headings prepended: ${groupedRecursiveHeadings}`); | |
| }); | |
| // =========================================================================== | |
| // 3) tables: full width + split-safe tweaks (AUTO-SIZE COLUMNS) | |
| // =========================================================================== | |
| group("Step 3 — Table print optimization (auto-sized columns + split-safe rows)", () => { | |
| const tables = qsa("table", root); | |
| info(`Tables detected: ${tables.length}`); | |
| tables.forEach((t, idx) => { | |
| dbg(`Table[${idx}] applying print rules…`, t); | |
| // Preserve outer edge borders during print fragmentation (no explicit border values) | |
| t.style.borderCollapse = "separate"; | |
| t.style.borderSpacing = "0"; | |
| // Fill width but allow columns to size to content | |
| t.style.display = "table"; | |
| t.style.width = "100%"; | |
| t.style.maxWidth = "100%"; | |
| // KEY CHANGE: let the browser auto-size columns (no fixed layout) | |
| t.style.tableLayout = "auto"; | |
| // Wrapping: prefer normal word breaking; only break long tokens if needed | |
| t.style.wordBreak = "normal"; | |
| const rows = qsa("tr", t); | |
| dbg(`Table[${idx}] rows: ${rows.length} → applying break-inside:avoid`); | |
| rows.forEach(avoidInside); | |
| const cells = qsa("td,th", t); | |
| dbg(`Table[${idx}] cells: ${cells.length} → applying cell break rules + wrapping safety net`); | |
| cells.forEach((c) => { | |
| avoidInside(c); | |
| c.style.overflowWrap = "break-word"; | |
| c.style.wordBreak = "normal"; | |
| }); | |
| const thead = qs("thead", t); | |
| if (thead) { | |
| thead.style.display = "table-header-group"; | |
| dbg(`Table[${idx}] thead detected; forcing repeat header group in print.`, thead); | |
| } | |
| const tfoot = qs("tfoot", t); | |
| if (tfoot) { | |
| tfoot.style.display = "table-footer-group"; | |
| dbg(`Table[${idx}] tfoot detected; forcing repeat footer group in print.`, tfoot); | |
| } | |
| }); | |
| info("Table optimization complete."); | |
| }); | |
| // =========================================================================== | |
| // 4) code blocks: avoid splitting | |
| // =========================================================================== | |
| group("Step 4 — Code blocks: keep intact across pages", () => { | |
| const codeBlocks = qsa("pre, pre > code, .highlight, .sourceCode", root); | |
| info(`Code-ish blocks detected: ${codeBlocks.length}`); | |
| codeBlocks.forEach((el, idx) => { | |
| dbg(`CodeBlock[${idx}] applying avoidInside…`, el); | |
| avoidInside(el); | |
| if (el.tagName?.toLowerCase() === "pre") { | |
| el.style.overflow = "visible"; | |
| dbg(`CodeBlock[${idx}] <pre> overflow set to visible for print clarity.`); | |
| } | |
| }); | |
| info("Code block print rules complete."); | |
| }); | |
| // =========================================================================== | |
| // 5) headings: avoid orphans, but don't double-constrain heading+table pairs | |
| // =========================================================================== | |
| group("Step 5 — Heading anti-orphan rules", () => { | |
| const headings = qsa("h1,h2,h3,h4,h5,h6", root); | |
| info(`Headings detected: ${headings.length}`); | |
| headings.forEach((h, idx) => { | |
| dbg(`Heading[${idx}] applying avoidAfter…`, h); | |
| avoidAfter(h); | |
| const next = h.nextElementSibling; | |
| const wrappedWithTable = !!h.closest?.(".keep-heading-with-table"); | |
| const nextIsTableish = | |
| next && | |
| (next.classList?.contains("markdown-accessibility-table") || | |
| next.tagName?.toLowerCase() === "table" || | |
| next.querySelector?.("table")); | |
| if (!wrappedWithTable && !nextIsTableish && next) { | |
| dbg(`Heading[${idx}] applying avoidBefore to immediate next content block…`, next); | |
| avoidBefore(next); | |
| } else { | |
| dbg( | |
| `Heading[${idx}] skip avoidBefore (wrappedWithTable=${wrappedWithTable}, nextIsTableish=${!!nextIsTableish}).` | |
| ); | |
| } | |
| }); | |
| info("Heading anti-orphan rules complete."); | |
| }); | |
| // =========================================================================== | |
| // 6) First-page fancy header (in-flow): filename + commit subtitle + breadcrumb + GitHub badge | |
| // - Appears only on page 1 because it's NOT fixed (normal flow) | |
| // - Theme-aware: white-ish in dark mode, black in print | |
| // - Tightens ONLY the top margin of the first H1 | |
| // =========================================================================== | |
| group("Step 6 — First-page header (filename + commit + breadcrumb + repo)", () => { | |
| const meta = window.__GH_PRINT_PREP__?.meta || {}; | |
| dbg("Step 6 using meta:", meta); | |
| // Avoid double-install | |
| if (window.__GH_PRINT_PREP__?.firstPageHeaderInstalled) { | |
| warn("Step 6 skipped: header already installed."); | |
| return; | |
| } | |
| // Parse owner/repo for right-side label | |
| const seg = (location.pathname || "").split("/").filter(Boolean); | |
| const owner = seg[0] || ""; | |
| const repo = seg[1] || ""; | |
| const repoFull = (owner && repo) ? `${owner}/${repo}` : ""; | |
| const filename = meta.filename || (meta.fileParts?.length ? meta.fileParts.at(-1) : "") || document.title || "(file)"; | |
| const suggestedPdfName = (() => { | |
| const cleanRepo = repo || "repo"; | |
| const cleanFile = filename || "document"; | |
| return `${cleanFile}${meta.hash7 ? ` (${meta.hash7})` : ""}`; | |
| })(); | |
| // Suggest filename via document.title | |
| document.title = suggestedPdfName; | |
| const formatCommitDate = () => { | |
| // Prefer title (includes timezone label), else iso, else display | |
| if (meta.title) return meta.title; | |
| if (meta.iso) { | |
| try { | |
| const d = new Date(meta.iso); | |
| return d.toLocaleString(undefined, { | |
| year: "numeric", | |
| month: "short", | |
| day: "numeric", | |
| hour: "numeric", | |
| minute: "2-digit", | |
| timeZoneName: "short", | |
| }); | |
| } catch { } | |
| } | |
| return meta.display || null; | |
| }; | |
| const subtitleText = (() => { | |
| const hash = meta.hash7 ? String(meta.hash7) : null; | |
| const dateStr = formatCommitDate(); | |
| if (!hash && !dateStr) return null; | |
| if (hash && dateStr) return `${hash} · ${dateStr}`; | |
| return hash || dateStr; | |
| })(); | |
| // ------------------------- | |
| // Header DOM (underline style, in-flow) | |
| // ------------------------- | |
| const header = document.createElement("div"); | |
| header.className = "gh-first-page-header"; | |
| avoidInside(header); | |
| // ------------------------- | |
| // First-page-only top pull-up (does NOT affect later pages) | |
| // ------------------------- | |
| const firstPageWrap = document.createElement("div"); | |
| firstPageWrap.className = "gh-first-page-wrap"; | |
| // Pull just the FIRST PAGE upward by N pixels. | |
| // Tune this number: 12..28 is usually the sweet spot. | |
| const PULL_UP_PX = 26; | |
| setStyle(firstPageWrap, { | |
| marginTop: `-${PULL_UP_PX}px`, | |
| }, "first-page pull-up wrapper"); | |
| // Underline only (GitHub-ish) | |
| setStyle(header, { | |
| display: "block", | |
| width: "100%", | |
| boxSizing: "border-box", | |
| padding: "0 0 10px 0", | |
| margin: "0 0 12px 0", | |
| borderBottom: "1px solid rgba(0,0,0,0.25)", | |
| }, "first-page header base"); | |
| // Big centered filename | |
| const filenameLine = document.createElement("div"); | |
| filenameLine.className = "gh-first-page-filename"; | |
| filenameLine.textContent = filename; | |
| // (Your updated style) | |
| setStyle(filenameLine, { | |
| width: "100%", | |
| textAlign: "center", | |
| fontFamily: "ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial", | |
| fontSize: "2em", | |
| fontWeight: "700", | |
| letterSpacing: "0.2px", | |
| margin: "0 0 2px 0", | |
| whiteSpace: "nowrap", | |
| overflow: "hidden", | |
| textOverflow: "ellipsis", | |
| }, "filename line"); | |
| // Subtitle: [HASH7] · [DATETIME] | |
| const subtitleLine = document.createElement("div"); | |
| subtitleLine.className = "gh-first-page-subtitle"; | |
| subtitleLine.textContent = subtitleText || ""; | |
| setStyle(subtitleLine, { | |
| width: "100%", | |
| textAlign: "center", | |
| fontFamily: "ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial", | |
| fontSize: "14px", // fixed px tends to print sharper than em here | |
| fontWeight: "500", | |
| letterSpacing: "0px", // avoid fractional spacing blur | |
| margin: "0 0 2em 0", | |
| display: subtitleText ? "block" : "none", // hide without opacity | |
| overflow: "hidden", | |
| }, "subtitle line"); | |
| // Row: breadcrumb left, GitHub badge right | |
| const row = document.createElement("div"); | |
| setStyle(row, { | |
| display: "flex", | |
| alignItems: "center", | |
| justifyContent: "space-between", | |
| gap: "16px", | |
| width: "100%", | |
| minWidth: "0", | |
| }, "header row"); | |
| // Left: full breadcrumb | |
| const breadcrumb = document.createElement("div"); | |
| breadcrumb.className = "gh-breadcrumb"; | |
| setStyle(breadcrumb, { | |
| display: "flex", | |
| flexWrap: "wrap", | |
| alignItems: "baseline", | |
| gap: "6px", | |
| fontFamily: "ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial", | |
| fontSize: "12px", | |
| lineHeight: "1.2", | |
| minWidth: "0", | |
| }, "breadcrumb"); | |
| const makeSpan = (text, extraStyle = {}) => { | |
| const s = document.createElement("span"); | |
| s.textContent = text; | |
| setStyle(s, Object.assign({ whiteSpace: "nowrap" }, extraStyle)); | |
| return s; | |
| }; | |
| const repoName = repo || meta.fileParts?.[0] || "(repo)"; | |
| const repoSpan = makeSpan(repoName, { fontWeight: "600" }); | |
| breadcrumb.appendChild(repoSpan); | |
| const parts = meta.fileParts?.length ? meta.fileParts : (meta.filePath ? meta.filePath.split("/").filter(Boolean) : ["(path)"]); | |
| parts.forEach((p) => { | |
| const sep = makeSpan("›", { | |
| margin: "0 2px", | |
| userSelect: "none", | |
| }); | |
| sep.className = "gh-breadcrumb-sep"; | |
| breadcrumb.appendChild(sep); | |
| breadcrumb.appendChild(makeSpan(p)); | |
| }); | |
| // Right: GitHub logo + owner/repo | |
| const right = document.createElement("div"); | |
| setStyle(right, { | |
| display: "flex", | |
| alignItems: "center", | |
| gap: "8px", | |
| flex: "0 0 auto", | |
| fontFamily: "ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial", | |
| fontSize: "12px", | |
| maxWidth: "45%", | |
| whiteSpace: "normal", | |
| justifyContent: "flex-end", | |
| textAlign: "right", | |
| minWidth: "0", | |
| }, "github badge"); | |
| const ghSvg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); | |
| ghSvg.setAttribute("viewBox", "0 0 16 16"); | |
| ghSvg.setAttribute("aria-hidden", "true"); | |
| ghSvg.setAttribute("focusable", "false"); | |
| ghSvg.innerHTML = | |
| `<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8Z"></path>`; | |
| ghSvg.setAttribute("fill", "currentColor"); | |
| setStyle(ghSvg, { width: "14px", height: "14px", flex: "0 0 auto" }, "gh svg"); | |
| const ghText = document.createElement("span"); | |
| ghText.textContent = (repoFull || "").replaceAll("/", "/\u200B"); | |
| setStyle(ghText, { fontWeight: "600" }, "gh text"); | |
| if (repoFull) { | |
| right.appendChild(ghSvg); | |
| right.appendChild(ghText); | |
| } | |
| row.appendChild(breadcrumb); | |
| row.appendChild(right); | |
| header.appendChild(filenameLine); | |
| header.appendChild(subtitleLine); | |
| header.appendChild(row); | |
| // Move root into wrapper so both header + the start of content shift up together. | |
| document.body.insertBefore(firstPageWrap, root); | |
| firstPageWrap.appendChild(header); | |
| firstPageWrap.appendChild(root); | |
| info("Inserted first-page header above markdown clone:", header); | |
| // ------------------------- | |
| // Only trim TOP margin on first H1 (do not touch bottom) | |
| // ------------------------- | |
| const firstH1 = qs("h1", root); | |
| if (firstH1) { | |
| setStyle(firstH1, { | |
| marginTop: "8px", // top only | |
| }, "first H1 top trim"); | |
| dbg("Trimmed first H1 top margin:", firstH1); | |
| } | |
| // ------------------------- | |
| // Theme-aware colors: dark mode = white-ish; print = black | |
| // ------------------------- | |
| const mqlDark = window.matchMedia?.("(prefers-color-scheme: dark)"); | |
| const applyTheme = (mode /* 'screen' | 'print' */) => { | |
| const dark = (mode !== "print") && !!mqlDark?.matches; | |
| const fg = dark ? "rgba(255,255,255,0.90)" : "rgba(0,0,0,0.80)"; | |
| const fgStrong = dark ? "rgba(255,255,255,0.95)" : "rgba(0,0,0,0.88)"; | |
| const rule = dark ? "rgba(255,255,255,0.22)" : "rgba(0,0,0,0.25)"; | |
| const sepColor = dark ? "rgba(255,255,255,0.45)" : "rgba(0,0,0,0.40)"; | |
| qsa(".gh-breadcrumb-sep", header).forEach((s) => { | |
| s.style.color = sepColor; | |
| }); | |
| header.style.borderBottom = `1px solid ${rule}`; | |
| filenameLine.style.color = fgStrong; | |
| subtitleLine.style.color = dark ? "rgba(255,255,255,0.70)" : "rgba(0,0,0,0.60)"; | |
| breadcrumb.style.color = fg; | |
| repoSpan.style.color = fgStrong; | |
| right.style.color = fg; | |
| }; | |
| applyTheme("screen"); | |
| // Reduce top "print margin feel" we control (browser margins still exist) | |
| const originalPadTop = document.body.style.paddingTop; | |
| const originalMarginTop = document.body.style.marginTop; | |
| const beforePrint = () => { | |
| applyTheme("print"); | |
| // shrink only the TOP padding so the first page starts closer to top | |
| document.body.style.paddingTop = "16px"; | |
| document.body.style.marginTop = "0px"; | |
| void header.offsetHeight; | |
| }; | |
| const afterPrint = () => { | |
| document.body.style.paddingTop = originalPadTop; | |
| document.body.style.marginTop = originalMarginTop; | |
| applyTheme("screen"); | |
| }; | |
| window.addEventListener("beforeprint", beforePrint); | |
| window.addEventListener("afterprint", afterPrint); | |
| const mqlPrint = window.matchMedia?.("print"); | |
| if (mqlPrint && typeof mqlPrint.addEventListener === "function") { | |
| mqlPrint.addEventListener("change", (e) => (e.matches ? beforePrint() : afterPrint())); | |
| } else if (mqlPrint && typeof mqlPrint.addListener === "function") { | |
| mqlPrint.addListener((e) => (e.matches ? beforePrint() : afterPrint())); | |
| } | |
| if (mqlDark && typeof mqlDark.addEventListener === "function") { | |
| mqlDark.addEventListener("change", () => applyTheme("screen")); | |
| } else if (mqlDark && typeof mqlDark.addListener === "function") { | |
| mqlDark.addListener(() => applyTheme("screen")); | |
| } | |
| window.__GH_PRINT_PREP__.firstPageHeaderInstalled = true; | |
| info("Step 6 complete."); | |
| }); | |
| // =========================================================================== | |
| // FINAL SUMMARY (one clean line, plus a nice expanded object) | |
| // =========================================================================== | |
| const summary = { | |
| groupedHeadingTablePairs: window.__GH_PRINT_PREP__.groupedHeadingTablePairs ?? 0, | |
| groupedHeadingNextPairs: window.__GH_PRINT_PREP__.groupedHeadingNextPairs ?? 0, | |
| prependedHeadingCount: window.__GH_PRINT_PREP__.prependedHeadingCount ?? 0, | |
| tables: qsa("table", root).length, | |
| codeBlocks: qsa("pre, pre > code, .highlight, .sourceCode", root).length, | |
| headings: qsa("h1,h2,h3,h4,h5,h6", root).length, | |
| }; | |
| log( | |
| `Done: isolated markdown; grouped ${summary.groupedHeadingTablePairs} heading+table pairs; ` + | |
| `grouped ${summary.groupedHeadingNextPairs} heading+next blocks; ` + | |
| `prepended ${summary.prependedHeadingCount} preceding headings into keep-wrappers; ` + | |
| `tables full-width with auto-sized columns; split-safe rules applied.` | |
| ); | |
| dbg("Run summary:", summary); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment