Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions contrib/BearMarkdown.popclipext/Config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
name: Bear Markdown
identifier: nl.pkops.popclip.bear-markdown
description: Create a new Bear note from the selected text, preserving formatting as Markdown.
icon: iconify:simple-icons:bear
requirements: [text]
capture html: true
app:
name: Bear
link: https://bear.app/
check installed: true
bundle identifiers:
- net.shinyfrog.bear
javascript file: bear-markdown.js
26 changes: 26 additions & 0 deletions contrib/BearMarkdown.popclipext/Readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Bear Markdown

Create a new note in Bear from the selected text, preserving supported formatting as Markdown.

When the selected text comes from a browser where PopClip can read page info, the extension adds the page title and URL as a source link at the bottom of the note.

The extension asks PopClip to capture HTML when available. If the captured HTML includes tables, Bear Markdown converts those tables to Markdown tables before opening Bear.

## Requirements

- Bear for macOS
- PopClip with JavaScript extension support

## Notes

Bear does not display remote Markdown images inline in all contexts. Remote image references are preserved as Markdown links or image syntax where PopClip provides them, but the extension does not download or attach image files.

Complex HTML tables with `rowspan` or `colspan` are converted on a best-effort basis.

## Author

Created by pkops.

## Changelog

- 2026-05-03: Initial contribution.
119 changes: 119 additions & 0 deletions contrib/BearMarkdown.popclipext/bear-markdown.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
function decodeEntities(value) {
const entities = {
amp: "&",
apos: "'",
gt: ">",
lt: "<",
nbsp: " ",
quot: "\""
};

return value.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, function(match, entity) {
if (entity[0] === "#") {
const radix = entity[1].toLowerCase() === "x" ? 16 : 10;
const code = parseInt(entity.replace(/^#x?/i, ""), radix);
return isNaN(code) ? match : String.fromCharCode(code);
}

return entities[entity.toLowerCase()] || match;
});
}

function cellToMarkdown(html) {
return decodeEntities(html)
.replace(/<br\s*\/?>/gi, " ")
.replace(/<\/p\s*>/gi, " ")
.replace(/<a\b[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi, "[$2]($1)")
.replace(/<[^>]+>/g, "")
.replace(/\s+/g, " ")
.replace(/\|/g, "\\|")
.trim();
}

function tableToMarkdown(tableHtml) {
const rows = [];
const rowMatches = tableHtml.match(/<tr\b[\s\S]*?<\/tr>/gi) || [];

for (const rowHtml of rowMatches) {
const cells = [];
const cellRegex = /<t[hd]\b[^>]*>([\s\S]*?)<\/t[hd]>/gi;
let match;

while ((match = cellRegex.exec(rowHtml)) !== null) {
cells.push(cellToMarkdown(match[1]));
}

if (cells.length > 0) {
rows.push(cells);
}
}

if (rows.length === 0) {
return "";
}

const columnCount = rows.reduce(function(max, row) {
return Math.max(max, row.length);
}, 0);

const normalizedRows = rows.map(function(row) {
const normalized = row.slice();
while (normalized.length < columnCount) {
normalized.push("");
}
return normalized;
});

const separator = [];
for (let index = 0; index < columnCount; index++) {
separator.push("---");
}

return [
"| " + normalizedRows[0].join(" | ") + " |",
"| " + separator.join(" | ") + " |"
].concat(normalizedRows.slice(1).map(function(row) {
return "| " + row.join(" | ") + " |";
})).join("\n");
}

function htmlToMarkdownWithTables(html) {
const converted = decodeEntities(html)
.replace(/<table\b[\s\S]*?<\/table>/gi, function(tableHtml) {
return "\n\n" + tableToMarkdown(tableHtml) + "\n\n";
})
.replace(/<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/gi, function(match, level, content) {
const prefix = "#".repeat(parseInt(level, 10));
return "\n\n" + prefix + " " + cellToMarkdown(content) + "\n\n";
})
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/(p|div|section|article|header|footer|h[1-6]|li)\s*>/gi, "\n")
.replace(/<a\b[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi, "[$2]($1)")
.replace(/<(strong|b)\b[^>]*>([\s\S]*?)<\/\1>/gi, "**$2**")
.replace(/<(em|i)\b[^>]*>([\s\S]*?)<\/\1>/gi, "_$2_")
.replace(/<[^>]+>/g, "")
.replace(/[ \t]+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();

return converted || popclip.input.markdown || popclip.input.text;
}

const html = popclip.input.html || "";
let markdown = /<table\b/i.test(html)
? htmlToMarkdownWithTables(html)
: (popclip.input.markdown || popclip.input.text);

const browserUrl = popclip.context.browserUrl;
const browserTitle = popclip.context.browserTitle || "Source";

if (browserUrl) {
markdown += "\n\n---\n\nSource: [" + browserTitle + "](" + browserUrl + ")";
}

const url = "bear://x-callback-url/create"
+ "?text=" + encodeURIComponent(markdown)
+ "&open_note=yes"
+ "&edit=yes";

popclip.openUrl(url);