Created
July 26, 2026 15:37
-
-
Save robertovg/c6ed0ef05a3d1d4c958f551c2abb54c8 to your computer and use it in GitHub Desktop.
Full Site to Markdown Extractor (Bookmarklet)
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
| /** | |
| * # Full Site to Markdown Extractor (Bookmarklet) | |
| * | |
| * A browser bookmarklet that converts the current page — and optionally other same-origin pages you pick — into clean Markdown, copied straight to your clipboard. | |
| * | |
| * ## Features | |
| * | |
| * - Converts headings, paragraphs, lists (nested), blockquotes, code blocks, tables, and links to Markdown. | |
| * - Lets you pick additional same-origin pages (via a link-selection modal) to bundle into one combined Markdown document. | |
| * - Optional toggles: include images, include inline content links, include links on page section headings, include a "pages selected" index. | |
| * - Special-cased extraction for Jira issue pages (Cloud & Data Center), pulling the rich-text description out of the Atlassian editor renderer. | |
| * - Strips noise elements (scripts, nav, ads, cookie banners, etc.) before conversion. | |
| * - Falls back to a popup window with the raw Markdown if clipboard access is unavailable. | |
| * | |
| * ## Usage | |
| * | |
| * 1.- Save the script as a bookmarklet (wrap in javascript:(...)() and minify, or use a bookmarklet-builder). | |
| * 2.- Click it on any page. | |
| * 3.- Optionally select related same-origin pages to include, choose export options, then click "Export Markdown". | |
| * 4.- Paste the result — Markdown is on your clipboard. | |
| * | |
| * ## Notes | |
| * | |
| * - Cross-page fetches only ever target the same origin as the current page (uses your existing session). | |
| * - No data is sent anywhere external — everything stays in the browser (clipboard/local popup only). | |
| * | |
| */ | |
| (async () => { | |
| const TIMEOUT_MS = 12000; | |
| let CONTENT_HEADING_OFFSET = 2; | |
| let exportOptions = { | |
| includeImagesInMd: false, | |
| includeContentLinks: false, | |
| includeLinksOnPageSections: false, | |
| includePagesSelectedPreSection: false, | |
| }; | |
| const normalizeWhitespace = (text) => | |
| (text || "").replace(/\s+/g, " ").replace(/\u00a0/g, " ").trim(); | |
| const toAbsoluteUrl = (href, baseUrl) => { | |
| try { | |
| return new URL(href, baseUrl).href; | |
| } catch { | |
| return null; | |
| } | |
| }; | |
| const escapeMarkdown = (text) => | |
| (text || "") | |
| .replace(/\\/g, "\\\\") | |
| .replace(/\[/g, "\\[") | |
| .replace(/\]/g, "\\]") | |
| .replace(/\(/g, "\\(") | |
| .replace(/\)/g, "\\)") | |
| .replace(/`/g, "\\`"); | |
| const isJiraUrl = (urlString) => { | |
| try { | |
| const url = new URL(urlString, window.location.href); | |
| return ( | |
| /atlassian\.net$/i.test(url.hostname) || | |
| /jira/i.test(url.hostname) || | |
| /\/browse\//i.test(url.pathname) || | |
| /\/jira\//i.test(url.pathname) | |
| ); | |
| } catch { | |
| return false; | |
| } | |
| }; | |
| const pickMainContainer = (doc, pageUrl) => { | |
| if (isJiraUrl(pageUrl)) { | |
| return ( | |
| doc.querySelector("[data-testid='issue.views.issue-base.foundation.content.content']") || | |
| doc.querySelector("[data-testid='issue.views.issue-base.foundation.main']") || | |
| doc.querySelector("[data-testid='issue-layout.ui.issue-layout']") || | |
| doc.querySelector(".issue-body-content") || | |
| doc.querySelector(".viewissue-content") || | |
| doc.querySelector("article") || | |
| doc.querySelector("main") || | |
| doc.querySelector("[role='main']") || | |
| doc.body | |
| ); | |
| } | |
| return ( | |
| doc.querySelector("article") || | |
| doc.querySelector("main") || | |
| doc.querySelector("[role='main']") || | |
| doc.body | |
| ); | |
| }; | |
| const extractJiraDescriptionMarkdown = (doc, pageUrl) => { | |
| const containerSelectors = [ | |
| // Jira Cloud — specific description field | |
| "[data-testid='issue.views.field.rich-text.description']", | |
| "[data-testid='issue.views.field.rich-text.description.content']", | |
| "[data-testid='issue.views.issue-base.description.description']", | |
| // Atlassian editor renderer (Cloud and Data Center) | |
| ".ak-renderer-document", | |
| // Jira Server / Data Center | |
| "#description-val .user-content-block", | |
| "#description-val", | |
| "[id*='description'][class*='value']", | |
| ]; | |
| for (const selector of containerSelectors) { | |
| const container = doc.querySelector(selector); | |
| if (!container) { | |
| continue; | |
| } | |
| const contentRoot = container.matches(".ak-renderer-document") | |
| ? container | |
| : container.querySelector(".ak-renderer-document") || container; | |
| // Process direct children of the container to preserve block structure. | |
| // Atlassian renderer may use <div>, <span>, <p>, or custom elements — process them all. | |
| const children = Array.from(contentRoot.childNodes); | |
| if (children.length === 0) { | |
| continue; | |
| } | |
| const parts = children | |
| .map((child) => { | |
| // Skip text nodes that are only whitespace | |
| if (child.nodeType === Node.TEXT_NODE) { | |
| const text = normalizeWhitespace(child.nodeValue || ""); | |
| return text ? text : ""; | |
| } | |
| // Process element nodes | |
| return convertBlock(child, pageUrl, 0).trim(); | |
| }) | |
| .filter(Boolean); | |
| if (parts.length > 0) { | |
| return parts.join("\n\n"); | |
| } | |
| } | |
| return ""; | |
| }; | |
| const removeNoise = (root) => { | |
| const selectors = [ | |
| "script", | |
| "style", | |
| "noscript", | |
| "svg", | |
| "canvas", | |
| "iframe", | |
| "nav", | |
| "header", | |
| "footer", | |
| "aside", | |
| "form", | |
| "button", | |
| "[aria-hidden='true']", | |
| ".ads", | |
| ".advertisement", | |
| ".cookie", | |
| ".newsletter", | |
| ]; | |
| root.querySelectorAll(selectors.join(",")).forEach((el) => el.remove()); | |
| }; | |
| const BLOCK_TAGS = new Set([ | |
| "article", | |
| "blockquote", | |
| "div", | |
| "h1", | |
| "h2", | |
| "h3", | |
| "h4", | |
| "h5", | |
| "h6", | |
| "hr", | |
| "li", | |
| "main", | |
| "ol", | |
| "p", | |
| "pre", | |
| "section", | |
| "table", | |
| "ul", | |
| ]); | |
| const isBlockElement = (node) => | |
| !!node && node.nodeType === Node.ELEMENT_NODE && BLOCK_TAGS.has(node.tagName.toLowerCase()); | |
| const convertContainerChildren = (node, baseUrl, depth) => { | |
| const parts = []; | |
| let inlineBuffer = ""; | |
| const flushInlineBuffer = () => { | |
| const text = normalizeWhitespace(inlineBuffer); | |
| if (text) { | |
| parts.push(`${text}\n\n`); | |
| } | |
| inlineBuffer = ""; | |
| }; | |
| Array.from(node.childNodes).forEach((child) => { | |
| if (isBlockElement(child)) { | |
| flushInlineBuffer(); | |
| parts.push(convertBlock(child, baseUrl, depth + 1)); | |
| return; | |
| } | |
| inlineBuffer += convertInline(child, baseUrl); | |
| }); | |
| flushInlineBuffer(); | |
| return parts.join(""); | |
| }; | |
| const convertListItem = (node, baseUrl, depth, marker) => { | |
| const contentParts = []; | |
| const nestedBlocks = []; | |
| let inlineBuffer = ""; | |
| const flushInlineBuffer = () => { | |
| const text = normalizeWhitespace(inlineBuffer); | |
| if (text) { | |
| contentParts.push(text); | |
| } | |
| inlineBuffer = ""; | |
| }; | |
| Array.from(node.childNodes).forEach((child) => { | |
| if (!isBlockElement(child)) { | |
| inlineBuffer += convertInline(child, baseUrl); | |
| return; | |
| } | |
| const childTag = child.tagName.toLowerCase(); | |
| if (childTag === "ul" || childTag === "ol") { | |
| flushInlineBuffer(); | |
| nestedBlocks.push(convertBlock(child, baseUrl, depth + 1).trimEnd()); | |
| return; | |
| } | |
| flushInlineBuffer(); | |
| const blockText = convertBlock(child, baseUrl, depth).trim(); | |
| if (blockText) { | |
| contentParts.push(blockText.replace(/\s*\n\s*/g, " ")); | |
| } | |
| }); | |
| flushInlineBuffer(); | |
| const indent = " ".repeat(depth); | |
| const text = normalizeWhitespace(contentParts.join(" ")); | |
| const line = text ? `${indent}${marker} ${text}` : `${indent}${marker}`; | |
| const nested = nestedBlocks.filter(Boolean).join("\n"); | |
| return nested ? `${line}\n${nested}` : line; | |
| }; | |
| const convertInline = (node, baseUrl) => { | |
| if (!node) { | |
| return ""; | |
| } | |
| if (node.nodeType === Node.TEXT_NODE) { | |
| return node.nodeValue || ""; | |
| } | |
| if (node.nodeType !== Node.ELEMENT_NODE) { | |
| return ""; | |
| } | |
| const tag = node.tagName.toLowerCase(); | |
| const children = Array.from(node.childNodes) | |
| .map((child) => convertInline(child, baseUrl)) | |
| .join(""); | |
| if (tag === "a") { | |
| const href = toAbsoluteUrl(node.getAttribute("href"), baseUrl) || ""; | |
| const label = normalizeWhitespace(children) || href; | |
| if (!exportOptions.includeContentLinks) { | |
| return label ? ` ${label} ` : ""; | |
| } | |
| return href ? `[${escapeMarkdown(label)}](${href})` : label; | |
| } | |
| if (tag === "strong" || tag === "b") { | |
| return `**${children.trim()}**`; | |
| } | |
| if (tag === "em" || tag === "i") { | |
| return `*${children.trim()}*`; | |
| } | |
| if (tag === "code") { | |
| return `\`${children.replace(/`/g, "\\`").trim()}\``; | |
| } | |
| if (tag === "img") { | |
| if (!exportOptions.includeImagesInMd) { | |
| return ""; | |
| } | |
| const src = toAbsoluteUrl(node.getAttribute("src"), baseUrl); | |
| const alt = normalizeWhitespace(node.getAttribute("alt") || "image"); | |
| return src ? `` : ""; | |
| } | |
| if (tag === "br") { | |
| return "\n"; | |
| } | |
| return children; | |
| }; | |
| const convertBlock = (node, baseUrl, depth = 0) => { | |
| if (!node) { | |
| return ""; | |
| } | |
| if (node.nodeType === Node.TEXT_NODE) { | |
| return normalizeWhitespace(node.nodeValue || ""); | |
| } | |
| if (node.nodeType !== Node.ELEMENT_NODE) { | |
| return ""; | |
| } | |
| const tag = node.tagName.toLowerCase(); | |
| if (["h1", "h2", "h3", "h4", "h5", "h6"].includes(tag)) { | |
| const level = Math.min(6, Number(tag.slice(1)) + CONTENT_HEADING_OFFSET); | |
| const text = normalizeWhitespace(convertInline(node, baseUrl)); | |
| return text ? `${"#".repeat(level)} ${text}\n\n` : ""; | |
| } | |
| if (tag === "p") { | |
| const text = normalizeWhitespace(convertInline(node, baseUrl)); | |
| return text ? `${text}\n\n` : ""; | |
| } | |
| if (tag === "blockquote") { | |
| const text = normalizeWhitespace(convertInline(node, baseUrl)); | |
| return text | |
| ? `${text | |
| .split("\n") | |
| .map((line) => `> ${line}`) | |
| .join("\n")}\n\n` | |
| : ""; | |
| } | |
| if (tag === "pre") { | |
| const code = node.textContent || ""; | |
| return code ? `\`\`\`\n${code.trim()}\n\`\`\`\n\n` : ""; | |
| } | |
| if (tag === "ul" || tag === "ol") { | |
| const items = Array.from(node.children).filter( | |
| (child) => child.tagName && child.tagName.toLowerCase() === "li" | |
| ); | |
| const lines = items | |
| .map((li, index) => { | |
| const marker = tag === "ol" ? `${index + 1}.` : "-"; | |
| const itemText = convertListItem(li, baseUrl, depth, marker); | |
| if (!itemText || itemText === `${" ".repeat(depth)}${marker}`) { | |
| return ""; | |
| } | |
| return itemText; | |
| }) | |
| .filter(Boolean) | |
| .join("\n"); | |
| return lines ? `${lines}\n\n` : ""; | |
| } | |
| if (tag === "hr") { | |
| return "---\n\n"; | |
| } | |
| if (tag === "table") { | |
| const rows = Array.from(node.querySelectorAll("tr")); | |
| if (!rows.length) { | |
| return ""; | |
| } | |
| const matrix = rows.map((row) => | |
| Array.from(row.querySelectorAll("th, td")).map((cell) => | |
| normalizeWhitespace(convertInline(cell, baseUrl)) | |
| ) | |
| ); | |
| const colCount = Math.max(...matrix.map((r) => r.length), 0); | |
| if (!colCount) { | |
| return ""; | |
| } | |
| const header = matrix[0].map((value) => value || " "); | |
| const separator = Array.from({ length: colCount }, () => "---"); | |
| const bodyRows = matrix.slice(1); | |
| const asRow = (arr) => | |
| `| ${Array.from({ length: colCount }, (_, i) => arr[i] || " ").join( | |
| " | " | |
| )} |`; | |
| return [asRow(header), asRow(separator), ...bodyRows.map(asRow)].join("\n") + "\n\n"; | |
| } | |
| const directTextTags = ["div", "section", "article", "main"]; | |
| if (directTextTags.includes(tag)) { | |
| const hasBlockChildren = Array.from(node.children).some((child) => isBlockElement(child)); | |
| if (!hasBlockChildren) { | |
| const text = normalizeWhitespace(convertInline(node, baseUrl)); | |
| return text ? `${text}\n\n` : ""; | |
| } | |
| return convertContainerChildren(node, baseUrl, depth); | |
| } | |
| return Array.from(node.childNodes) | |
| .map((child) => convertBlock(child, baseUrl, depth + 1)) | |
| .join(""); | |
| }; | |
| const htmlDocumentFromString = (htmlText) => | |
| new DOMParser().parseFromString(htmlText, "text/html"); | |
| const fetchHtml = async (url) => { | |
| const controller = new AbortController(); | |
| const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS); | |
| try { | |
| const res = await fetch(url, { | |
| method: "GET", | |
| credentials: "include", | |
| signal: controller.signal, | |
| }); | |
| if (!res.ok) { | |
| throw new Error(`HTTP ${res.status}`); | |
| } | |
| return await res.text(); | |
| } finally { | |
| clearTimeout(timeout); | |
| } | |
| }; | |
| const extractMarkdownFromDocument = (doc, pageUrl) => { | |
| const title = normalizeWhitespace(doc.title || pageUrl); | |
| const description = normalizeWhitespace( | |
| doc.querySelector("meta[name='description']")?.getAttribute("content") || "" | |
| ); | |
| const root = pickMainContainer(doc, pageUrl).cloneNode(true); | |
| removeNoise(root); | |
| let markdownBody = convertBlock(root, pageUrl) | |
| .replace(/\n{3,}/g, "\n\n") | |
| .trim(); | |
| // Jira renders a plain "Description" label where the rich-text content lives. | |
| // Inject the formatted description there; keep the rest of the body (subtasks, details…). | |
| if (isJiraUrl(pageUrl)) { | |
| const jiraDescription = extractJiraDescriptionMarkdown(doc, pageUrl); | |
| if (jiraDescription) { | |
| // Replace the bare "Description" line that Jira injects as a label | |
| if (/\nDescription\n/.test(markdownBody)) { | |
| markdownBody = markdownBody.replace(/\nDescription\n/, `\n\n${jiraDescription}\n\n`); | |
| } else if (markdownBody.startsWith("Description\n")) { | |
| markdownBody = markdownBody.replace(/^Description\n/, `${jiraDescription}\n\n`); | |
| } else { | |
| // Fallback: insert after the h1 title line | |
| markdownBody = markdownBody.replace(/^(#[^\n]+\n\n?)/, `$1${jiraDescription}\n\n`); | |
| } | |
| } | |
| } | |
| return { | |
| title, | |
| description, | |
| markdownBody, | |
| }; | |
| }; | |
| const collectSameOriginLinks = () => { | |
| const currentUrl = new URL(window.location.href); | |
| const seen = new Set(); | |
| const links = []; | |
| Array.from(document.querySelectorAll("a[href]")) | |
| .map((a) => ({ | |
| href: toAbsoluteUrl(a.getAttribute("href"), window.location.href), | |
| text: normalizeWhitespace(a.textContent), | |
| })) | |
| .forEach(({ href, text }) => { | |
| if (!href) { | |
| return; | |
| } | |
| const url = new URL(href); | |
| if (!["http:", "https:"].includes(url.protocol)) { | |
| return; | |
| } | |
| if (url.origin !== currentUrl.origin) { | |
| return; | |
| } | |
| url.hash = ""; | |
| const normalizedHref = url.href; | |
| if (normalizedHref === currentUrl.href || seen.has(normalizedHref)) { | |
| return; | |
| } | |
| seen.add(normalizedHref); | |
| links.push({ | |
| href: normalizedHref, | |
| text: text || normalizedHref, | |
| }); | |
| }); | |
| return links; | |
| }; | |
| const formatPageSectionHeading = (title, url) => { | |
| const safeTitle = escapeMarkdown(title); | |
| if (!exportOptions.includeLinksOnPageSections) { | |
| return `## ${safeTitle}`; | |
| } | |
| return `## [${safeTitle}](${url})`; | |
| }; | |
| const openSelectionModal = (currentPageTitle, links) => | |
| new Promise((resolve) => { | |
| const overlay = document.createElement("div"); | |
| overlay.style.position = "fixed"; | |
| overlay.style.inset = "0"; | |
| overlay.style.background = "rgba(0, 0, 0, 0.55)"; | |
| overlay.style.zIndex = "2147483647"; | |
| overlay.style.display = "flex"; | |
| overlay.style.alignItems = "center"; | |
| overlay.style.justifyContent = "center"; | |
| const panel = document.createElement("div"); | |
| panel.style.width = "min(920px, 92vw)"; | |
| panel.style.maxHeight = "88vh"; | |
| panel.style.overflow = "hidden"; | |
| panel.style.background = "#fff"; | |
| panel.style.color = "#111"; | |
| panel.style.borderRadius = "12px"; | |
| panel.style.padding = "16px"; | |
| panel.style.boxShadow = "0 16px 48px rgba(0, 0, 0, 0.35)"; | |
| panel.style.fontFamily = "ui-sans-serif, system-ui, sans-serif"; | |
| const title = document.createElement("h3"); | |
| title.textContent = "Select pages to export"; | |
| title.style.margin = "0 0 8px 0"; | |
| const subtitle = document.createElement("p"); | |
| subtitle.textContent = `Current page is always included: ${currentPageTitle}`; | |
| subtitle.style.margin = "0 0 12px 0"; | |
| subtitle.style.fontSize = "14px"; | |
| subtitle.style.opacity = "0.85"; | |
| const actions = document.createElement("div"); | |
| actions.style.display = "flex"; | |
| actions.style.gap = "8px"; | |
| actions.style.flexWrap = "wrap"; | |
| actions.style.marginBottom = "10px"; | |
| const searchInput = document.createElement("input"); | |
| searchInput.type = "search"; | |
| searchInput.placeholder = "Filter pages..."; | |
| searchInput.style.flex = "1"; | |
| searchInput.style.minWidth = "260px"; | |
| searchInput.style.padding = "8px 10px"; | |
| searchInput.style.border = "1px solid #c7c7c7"; | |
| searchInput.style.borderRadius = "8px"; | |
| const selectAllBtn = document.createElement("button"); | |
| selectAllBtn.type = "button"; | |
| selectAllBtn.textContent = "Select all"; | |
| selectAllBtn.style.padding = "8px 10px"; | |
| const clearAllBtn = document.createElement("button"); | |
| clearAllBtn.type = "button"; | |
| clearAllBtn.textContent = "Clear"; | |
| clearAllBtn.style.padding = "8px 10px"; | |
| actions.appendChild(searchInput); | |
| actions.appendChild(selectAllBtn); | |
| actions.appendChild(clearAllBtn); | |
| const list = document.createElement("div"); | |
| list.style.border = "1px solid #dcdcdc"; | |
| list.style.borderRadius = "8px"; | |
| list.style.padding = "8px"; | |
| list.style.maxHeight = "42vh"; | |
| list.style.overflow = "auto"; | |
| const rows = links.map((link, index) => { | |
| const row = document.createElement("label"); | |
| row.style.display = "flex"; | |
| row.style.gap = "8px"; | |
| row.style.padding = "6px"; | |
| row.style.cursor = "pointer"; | |
| row.style.borderRadius = "6px"; | |
| row.style.alignItems = "flex-start"; | |
| const checkbox = document.createElement("input"); | |
| checkbox.type = "checkbox"; | |
| checkbox.dataset.index = String(index); | |
| const textWrap = document.createElement("span"); | |
| textWrap.style.display = "flex"; | |
| textWrap.style.flexDirection = "column"; | |
| textWrap.style.gap = "2px"; | |
| const titleEl = document.createElement("span"); | |
| titleEl.textContent = link.text; | |
| titleEl.style.fontSize = "14px"; | |
| const urlEl = document.createElement("span"); | |
| urlEl.textContent = link.href; | |
| urlEl.style.fontSize = "12px"; | |
| urlEl.style.opacity = "0.75"; | |
| urlEl.style.wordBreak = "break-all"; | |
| textWrap.appendChild(titleEl); | |
| textWrap.appendChild(urlEl); | |
| row.appendChild(checkbox); | |
| row.appendChild(textWrap); | |
| list.appendChild(row); | |
| return { row, checkbox, link }; | |
| }); | |
| if (!rows.length) { | |
| const empty = document.createElement("div"); | |
| empty.textContent = "No same-origin links found on this page."; | |
| empty.style.opacity = "0.75"; | |
| empty.style.fontSize = "14px"; | |
| list.appendChild(empty); | |
| } | |
| const optionsWrap = document.createElement("div"); | |
| optionsWrap.style.marginTop = "12px"; | |
| optionsWrap.style.display = "grid"; | |
| optionsWrap.style.gap = "6px"; | |
| const makeOption = (labelText, checked = true) => { | |
| const label = document.createElement("label"); | |
| label.style.display = "flex"; | |
| label.style.alignItems = "center"; | |
| label.style.gap = "8px"; | |
| label.style.fontSize = "14px"; | |
| const input = document.createElement("input"); | |
| input.type = "checkbox"; | |
| input.checked = checked; | |
| label.appendChild(input); | |
| label.appendChild(document.createTextNode(labelText)); | |
| return { label, input }; | |
| }; | |
| const includeImagesOption = makeOption("Include images in MD", false); | |
| const includeContentLinksOption = makeOption("Include links in page content", false); | |
| const includeLinksOnSectionsOption = makeOption("Include links on page sections", false); | |
| const includePagesSelectedOption = makeOption("Include pages selected pre-section", false); | |
| optionsWrap.appendChild(includeImagesOption.label); | |
| optionsWrap.appendChild(includeContentLinksOption.label); | |
| optionsWrap.appendChild(includeLinksOnSectionsOption.label); | |
| optionsWrap.appendChild(includePagesSelectedOption.label); | |
| const footer = document.createElement("div"); | |
| footer.style.display = "flex"; | |
| footer.style.justifyContent = "space-between"; | |
| footer.style.alignItems = "center"; | |
| footer.style.marginTop = "14px"; | |
| const count = document.createElement("span"); | |
| count.style.fontSize = "13px"; | |
| count.style.opacity = "0.8"; | |
| const footerButtons = document.createElement("div"); | |
| footerButtons.style.display = "flex"; | |
| footerButtons.style.gap = "8px"; | |
| const cancelBtn = document.createElement("button"); | |
| cancelBtn.type = "button"; | |
| cancelBtn.textContent = "Cancel"; | |
| cancelBtn.style.padding = "8px 12px"; | |
| const runBtn = document.createElement("button"); | |
| runBtn.type = "button"; | |
| runBtn.textContent = "Export Markdown"; | |
| runBtn.style.padding = "8px 12px"; | |
| runBtn.style.fontWeight = "600"; | |
| footerButtons.appendChild(cancelBtn); | |
| footerButtons.appendChild(runBtn); | |
| footer.appendChild(count); | |
| footer.appendChild(footerButtons); | |
| panel.appendChild(title); | |
| panel.appendChild(subtitle); | |
| panel.appendChild(actions); | |
| panel.appendChild(list); | |
| panel.appendChild(optionsWrap); | |
| panel.appendChild(footer); | |
| overlay.appendChild(panel); | |
| document.body.appendChild(overlay); | |
| const visibleRows = () => rows.filter((item) => item.row.style.display !== "none"); | |
| const updateCount = () => { | |
| const selected = rows.filter((item) => item.checkbox.checked).length; | |
| count.textContent = `${selected} selected`; | |
| }; | |
| const close = (result) => { | |
| document.removeEventListener("keydown", onKeyDown); | |
| overlay.remove(); | |
| resolve(result); | |
| }; | |
| const applyFilter = () => { | |
| const query = normalizeWhitespace(searchInput.value).toLowerCase(); | |
| rows.forEach((item) => { | |
| const haystack = `${item.link.text} ${item.link.href}`.toLowerCase(); | |
| item.row.style.display = !query || haystack.includes(query) ? "flex" : "none"; | |
| }); | |
| }; | |
| const onKeyDown = (event) => { | |
| if (event.key === "Escape") { | |
| close(null); | |
| return; | |
| } | |
| if (event.key === "Enter") { | |
| event.preventDefault(); | |
| runBtn.click(); | |
| } | |
| }; | |
| rows.forEach((item) => { | |
| item.checkbox.addEventListener("change", updateCount); | |
| }); | |
| selectAllBtn.addEventListener("click", () => { | |
| visibleRows().forEach((item) => { | |
| item.checkbox.checked = true; | |
| }); | |
| updateCount(); | |
| }); | |
| clearAllBtn.addEventListener("click", () => { | |
| rows.forEach((item) => { | |
| item.checkbox.checked = false; | |
| }); | |
| updateCount(); | |
| }); | |
| searchInput.addEventListener("input", applyFilter); | |
| cancelBtn.addEventListener("click", () => close(null)); | |
| runBtn.addEventListener("click", () => { | |
| const selectedLinks = rows | |
| .filter((item) => item.checkbox.checked) | |
| .map((item) => item.link); | |
| close({ | |
| selectedLinks, | |
| options: { | |
| includeImagesInMd: !!includeImagesOption.input.checked, | |
| includeContentLinks: !!includeContentLinksOption.input.checked, | |
| includeLinksOnPageSections: !!includeLinksOnSectionsOption.input.checked, | |
| includePagesSelectedPreSection: !!includePagesSelectedOption.input.checked, | |
| }, | |
| }); | |
| }); | |
| overlay.addEventListener("click", (event) => { | |
| if (event.target === overlay) { | |
| close(null); | |
| } | |
| }); | |
| document.addEventListener("keydown", onKeyDown); | |
| searchInput.focus(); | |
| updateCount(); | |
| }); | |
| const copyToClipboard = async (text) => { | |
| try { | |
| await navigator.clipboard.writeText(text); | |
| alert("Markdown copied to clipboard."); | |
| return; | |
| } catch { | |
| const fallback = window.open("", "_blank", "width=800,height=600"); | |
| if (fallback) { | |
| fallback.document.write(`<pre>${text.replace(/</g, "<")}</pre>`); | |
| } else { | |
| alert("Could not copy automatically. Please copy from the console output."); | |
| } | |
| console.log(text); | |
| } | |
| }; | |
| const currentPageTitle = normalizeWhitespace(document.title || window.location.href); | |
| const candidateLinks = collectSameOriginLinks(); | |
| const siteTitle = normalizeWhitespace( | |
| document.querySelector("meta[property='og:site_name']")?.getAttribute("content") || | |
| window.location.hostname | |
| ); | |
| const siteUrl = window.location.origin; | |
| const selectionResult = await openSelectionModal(currentPageTitle, candidateLinks); | |
| if (!selectionResult) { | |
| return; | |
| } | |
| const selectedLinks = selectionResult.selectedLinks; | |
| exportOptions = { | |
| ...exportOptions, | |
| ...(selectionResult.options || {}), | |
| }; | |
| const isSinglePage = selectedLinks.length === 0; | |
| if (isSinglePage) { | |
| CONTENT_HEADING_OFFSET = 1; | |
| } | |
| const currentPage = extractMarkdownFromDocument(document, window.location.href); | |
| const sections = []; | |
| if (isSinglePage) { | |
| sections.push(`# [${escapeMarkdown(currentPage.title)}](${window.location.href})`); | |
| sections.push(""); | |
| if (currentPage.description) { | |
| sections.push(currentPage.description); | |
| sections.push(""); | |
| } | |
| sections.push(currentPage.markdownBody || "No extractable content."); | |
| } else { | |
| sections.push(`# [${escapeMarkdown(siteTitle)}](${siteUrl})`); | |
| sections.push(""); | |
| sections.push(currentPage.description || "No meta description."); | |
| sections.push(""); | |
| if (exportOptions.includePagesSelectedPreSection !== false) { | |
| sections.push("## Pages selected"); | |
| sections.push(`- [${escapeMarkdown(currentPage.title)}](${window.location.href})`); | |
| for (const link of selectedLinks) { | |
| sections.push(`- [${escapeMarkdown(link.text)}](${link.href})`); | |
| } | |
| sections.push(""); | |
| } | |
| sections.push(formatPageSectionHeading(currentPage.title, window.location.href)); | |
| sections.push(currentPage.markdownBody || "No extractable content."); | |
| for (const link of selectedLinks) { | |
| try { | |
| const htmlText = await fetchHtml(link.href); | |
| const doc = htmlDocumentFromString(htmlText); | |
| const extracted = extractMarkdownFromDocument(doc, link.href); | |
| sections.push(""); | |
| sections.push(formatPageSectionHeading(extracted.title, link.href)); | |
| sections.push(extracted.markdownBody || "No extractable content."); | |
| } catch (error) { | |
| sections.push(""); | |
| sections.push(formatPageSectionHeading(link.text, link.href)); | |
| sections.push(`_Could not extract this page: ${normalizeWhitespace(error.message)}_`); | |
| } | |
| } | |
| } | |
| const markdownOutput = sections.join("\n").replace(/\n{3,}/g, "\n\n").trim(); | |
| await copyToClipboard(markdownOutput); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment