Add enhanced Markdown to LinkedIn conversion and X thread splitter

This commit is contained in:
2026-04-29 23:28:28 +02:00
parent a7db8ef418
commit 834451f33b
6 changed files with 417 additions and 55 deletions
+13
View File
@@ -1,5 +1,7 @@
import { Plugin } from "obsidian";
import { transformText, cleanText, bulletToEmdash, bulletToArrow, numberedListSlash, numberedListParens, markdownToLinkedIn, FormatStyle } from "./formatter";
import { splitIntoThreads } from "./thread-splitter";
import { ThreadModal } from "./thread-modal";
function addFormatCommand(plugin: Plugin, style: FormatStyle, name: string) {
plugin.addCommand({
@@ -78,4 +80,15 @@ export function registerCommands(plugin: Plugin): void {
}
},
});
plugin.addCommand({
id: "unicode-formatter:split-thread",
name: "Split into X thread",
editorCallback: (editor) => {
const selection = editor.getSelection();
if (selection) {
const tweets = splitIntoThreads(selection);
new ThreadModal(plugin.app, tweets).open();
}
},
});
}
+1 -50
View File
@@ -75,53 +75,4 @@ export function numberedListParens(text: string): string {
}).join("\n");
}
export function applyInlineMarkdown(text: string): string {
return text.replace(/\*\*(.+?)\*\*|_(.+?)_/g, (_match, bold: string | undefined, italic: string | undefined) => {
if (bold !== undefined) return transformText(bold, "bold");
return transformText(italic!, "italic");
});
}
export function markdownToLinkedIn(text: string): string {
const lines = text.split("\n");
const output: string[] = [];
let pendingBlanks = 0;
let skipNextBlanks = false;
let inListContext = false;
for (const line of lines) {
if (line.trim() === "") {
if (!skipNextBlanks) pendingBlanks++;
continue;
}
skipNextBlanks = false;
if (line.startsWith("## ")) {
for (let i = 0; i < pendingBlanks; i++) output.push("");
pendingBlanks = 0;
inListContext = false;
const headingText = line.slice(3);
output.push(transformText(applyInlineMarkdown(headingText), "bold") + " ");
skipNextBlanks = true;
continue;
}
if (line.startsWith("- ") || line.startsWith("* ")) {
if (!inListContext) {
for (let i = 0; i < pendingBlanks; i++) output.push("");
}
pendingBlanks = 0;
output.push("— " + applyInlineMarkdown(line.slice(2)) + " ");
inListContext = true;
continue;
}
for (let i = 0; i < pendingBlanks; i++) output.push("");
pendingBlanks = 0;
inListContext = false;
output.push(applyInlineMarkdown(line));
}
return output.join("\n").replace(/[ \u00A0]\./g, ".").trimEnd();
}
export { markdownToLinkedIn } from "./linkedin";
+115
View File
@@ -0,0 +1,115 @@
import { transformText } from "./formatter";
function stripUnsupportedMarkdown(text: string): string {
return text
.replace(/!\[[^\]]*\]\([^)]+\)/g, "")
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1 ($2)")
.replace(/~~(.+?)~~/g, "$1")
.replace(/`(.+?)`/g, "$1");
}
function applyInlineMarkdown(text: string): string {
return text.replace(
/\*\*\*(.+?)\*\*\*|\*\*(.+?)\*\*|_(.+?)_/g,
(_match: string, boldItalic: string | undefined, bold: string | undefined, italic: string | undefined) => {
if (boldItalic !== undefined) return transformText(boldItalic, "bold-italic");
if (bold !== undefined) return transformText(bold, "bold");
return transformText(italic!, "italic");
}
);
}
export function markdownToLinkedIn(text: string): string {
const processed = stripUnsupportedMarkdown(text);
const lines = processed.split("\n");
const output: string[] = [];
let pendingBlanks = 0;
let skipNextBlanks = false;
let inListContext = false;
for (const line of lines) {
if (line.trim() === "") {
if (!skipNextBlanks) pendingBlanks++;
continue;
}
skipNextBlanks = false;
if (line.startsWith("# ")) {
for (let i = 0; i < pendingBlanks; i++) output.push("");
pendingBlanks = 0;
inListContext = false;
const headingText = line.slice(2).toUpperCase();
output.push(transformText(applyInlineMarkdown(headingText), "bold") + " ");
skipNextBlanks = true;
continue;
}
if (line.startsWith("## ")) {
for (let i = 0; i < pendingBlanks; i++) output.push("");
pendingBlanks = 0;
inListContext = false;
const headingText = line.slice(3);
output.push(transformText(applyInlineMarkdown(headingText), "bold") + " ");
skipNextBlanks = true;
continue;
}
const hMatch = line.match(/^#{3,6}\s/);
if (hMatch) {
for (let i = 0; i < pendingBlanks; i++) output.push("");
pendingBlanks = 0;
inListContext = false;
const headingText = line.slice(hMatch[0].length);
output.push(transformText(applyInlineMarkdown(headingText), "bold") + " ");
skipNextBlanks = true;
continue;
}
if (line.startsWith("> ")) {
for (let i = 0; i < pendingBlanks; i++) output.push("");
pendingBlanks = 0;
inListContext = false;
output.push(transformText(applyInlineMarkdown(line.slice(2)), "italic"));
continue;
}
if (line.startsWith("- ") || line.startsWith("* ")) {
if (!inListContext) {
for (let i = 0; i < pendingBlanks; i++) output.push("");
}
pendingBlanks = 0;
output.push("— " + applyInlineMarkdown(line.slice(2)) + " ");
inListContext = true;
continue;
}
const orderedMatch = line.match(/^(\d+)[.)]\s+(.+)/);
if (orderedMatch) {
if (!inListContext) {
for (let i = 0; i < pendingBlanks; i++) output.push("");
}
pendingBlanks = 0;
const num = orderedMatch[1]!;
const content = orderedMatch[2]!;
output.push(`${num}/ ${applyInlineMarkdown(content)} `);
inListContext = true;
continue;
}
if (/^-{3,}$/.test(line.trim()) || /^\*{3,}$/.test(line.trim())) {
for (let i = 0; i < pendingBlanks; i++) output.push("");
pendingBlanks = 0;
inListContext = false;
output.push("");
continue;
}
for (let i = 0; i < pendingBlanks; i++) output.push("");
pendingBlanks = 0;
inListContext = false;
output.push(applyInlineMarkdown(line));
}
return output.join("\n").replace(/[ \u00A0]\./g, ".").trimEnd();
}
+72
View File
@@ -0,0 +1,72 @@
import { App, Modal } from "obsidian";
export class ThreadModal extends Modal {
private tweets: string[];
constructor(app: App, tweets: string[]) {
super(app);
this.tweets = tweets;
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("unicode-thread-modal");
const headingText = this.tweets.length === 1
? "X post"
: `X thread (${this.tweets.length} posts)`;
contentEl.createEl("h3", { text: headingText });
const list = contentEl.createDiv({ cls: "thread-tweet-list" });
for (let i = 0; i < this.tweets.length; i++) {
const tweet = this.tweets[i]!;
const item = list.createDiv({ cls: "thread-tweet-item" });
const header = item.createDiv({ cls: "thread-tweet-header" });
if (this.tweets.length > 1) {
header.createSpan({
text: `Post ${i + 1}/${this.tweets.length}`,
cls: "thread-tweet-label",
});
}
item.createEl("textarea", {
text: tweet,
attr: { readonly: "true" },
cls: "thread-tweet-textarea",
});
const copyBtn = item.createEl("button", {
text: "Copy",
cls: "thread-copy-button",
});
copyBtn.addEventListener("click", () => {
navigator.clipboard.writeText(tweet).then(() => {
copyBtn.setText("Copied");
setTimeout(() => copyBtn.setText("Copy"), 2000);
}).catch(() => {});
});
}
if (this.tweets.length > 1) {
const copyAllBtn = contentEl.createEl("button", {
text: "Copy all",
cls: "thread-copy-all-button",
});
copyAllBtn.addEventListener("click", () => {
const all = this.tweets.join("\n\n");
navigator.clipboard.writeText(all).then(() => {
copyAllBtn.setText("Copied");
setTimeout(() => copyAllBtn.setText("Copy all"), 2000);
}).catch(() => {});
});
}
}
onClose(): void {
const { contentEl } = this;
contentEl.empty();
}
}
+174
View File
@@ -0,0 +1,174 @@
export interface ThreadOptions {
maxChars: number;
addNumbering: boolean;
}
const DEFAULT_OPTIONS: ThreadOptions = {
maxChars: 280,
addNumbering: true,
};
const NUMBERING_RESERVE = 8;
function countChars(text: string): number {
let count = 0;
let lastIndex = 0;
const urlRe = /https?:\/\/\S+/g;
let match: RegExpExecArray | null;
while ((match = urlRe.exec(text)) !== null) {
count += match.index - lastIndex;
count += 23;
lastIndex = urlRe.lastIndex;
}
count += text.length - lastIndex;
return count;
}
function textFits(text: string, max: number): boolean {
return countChars(text) <= max;
}
function splitByWords(text: string, max: number): string[] {
const words = text.split(/\s+/);
const chunks: string[] = [];
let current = "";
for (const word of words) {
if (!current) {
current = word;
continue;
}
const candidate = current + " " + word;
if (countChars(candidate) <= max - 3) {
current = candidate;
} else {
chunks.push(current + "...");
current = word;
}
}
if (current) chunks.push(current);
return chunks;
}
function splitBySentences(text: string, max: number): string[] {
const sentences = text.match(/[^.!?\s][^.!?]*(?:[.!?]+|$)/g) || [text];
const chunks: string[] = [];
let current = "";
for (const sentence of sentences) {
if (!current) {
if (textFits(sentence, max)) {
current = sentence;
} else {
chunks.push(...splitByWords(sentence, max));
}
} else {
const candidate = current + sentence;
if (textFits(candidate, max)) {
current = candidate;
} else {
chunks.push(current);
if (textFits(sentence, max)) {
current = sentence;
} else {
chunks.push(...splitByWords(sentence, max));
current = "";
}
}
}
}
if (current) chunks.push(current);
return chunks;
}
function splitBlock(text: string, max: number): string[] {
const lines = text.split("\n").filter(l => l.trim());
const chunks: string[] = [];
let current = "";
for (const line of lines) {
const trimmed = line.trim();
if (!current) {
if (textFits(trimmed, max)) {
current = trimmed;
} else {
chunks.push(...splitBySentences(trimmed, max));
}
} else {
const candidate = current + "\n" + trimmed;
if (textFits(candidate, max)) {
current = candidate;
} else {
chunks.push(current);
if (textFits(trimmed, max)) {
current = trimmed;
} else {
chunks.push(...splitBySentences(trimmed, max));
current = "";
}
}
}
}
if (current) chunks.push(current);
return chunks.length > 0 ? chunks : [text];
}
export function splitIntoThreads(text: string, options?: Partial<ThreadOptions>): string[] {
const opts: ThreadOptions = Object.assign({}, DEFAULT_OPTIONS, options);
const effectiveMax = opts.addNumbering ? opts.maxChars - NUMBERING_RESERVE : opts.maxChars;
const paragraphs = text.split(/\n{2,}/).filter(p => p.trim());
if (paragraphs.length === 0) return [];
const rawTweets: string[] = [];
let current = "";
for (const para of paragraphs) {
const trimmed = para.trim();
if (!current) {
if (textFits(trimmed, effectiveMax)) {
current = trimmed;
} else {
const chunks = splitBlock(trimmed, effectiveMax);
if (chunks.length > 1) {
rawTweets.push(...chunks.slice(0, -1));
current = chunks[chunks.length - 1]!;
} else {
current = chunks[0]!;
}
}
} else {
const candidate = current + "\n\n" + trimmed;
if (textFits(candidate, effectiveMax)) {
current = candidate;
} else {
rawTweets.push(current);
if (textFits(trimmed, effectiveMax)) {
current = trimmed;
} else {
const chunks = splitBlock(trimmed, effectiveMax);
if (chunks.length > 1) {
rawTweets.push(...chunks.slice(0, -1));
current = chunks[chunks.length - 1]!;
} else {
current = chunks[0]!;
}
}
}
}
}
if (current) rawTweets.push(current);
if (opts.addNumbering && rawTweets.length > 1) {
const total = rawTweets.length;
return rawTweets.map((tweet, i) => `${i + 1}/${total} ${tweet}`);
}
return rawTweets;
}
+42 -5
View File
@@ -1,8 +1,45 @@
/*
.unicode-thread-modal .thread-tweet-list {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 16px;
}
This CSS file will be included with your plugin, and
available in the app when your plugin is enabled.
.unicode-thread-modal .thread-tweet-item {
display: flex;
flex-direction: column;
gap: 4px;
}
If your plugin does not need CSS, delete this file.
.unicode-thread-modal .thread-tweet-header {
display: flex;
align-items: center;
gap: 8px;
}
*/
.unicode-thread-modal .thread-tweet-label {
font-weight: 600;
font-size: 0.85em;
color: var(--text-muted);
}
.unicode-thread-modal .thread-tweet-textarea {
width: 100%;
min-height: 60px;
resize: vertical;
font-family: var(--font-text);
font-size: var(--font-text-size);
padding: 8px;
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
background: var(--background-secondary);
color: var(--text-normal);
}
.unicode-thread-modal .thread-copy-button {
align-self: flex-end;
}
.unicode-thread-modal .thread-copy-all-button {
width: 100%;
}