Skip to content

Instantly share code, notes, and snippets.

@johnd0e
Last active April 12, 2026 10:37
Show Gist options
  • Select an option

  • Save johnd0e/c9b9ebe499a60f792e23a3345e06d1be to your computer and use it in GitHub Desktop.

Select an option

Save johnd0e/c9b9ebe499a60f792e23a3345e06d1be to your computer and use it in GitHub Desktop.
LinkedIn Editor EOL Formatter

LinkedIn Editor EOL Formatter

Why this script exists

LinkedIn's official data export (Member Data Portability snapshot) and API access both suffer from a serious limitation: they strip line breaks and line-ending whitespace from free-text fields (e.g., "About", "Experience", "Summary").

As a result:

  • Carefully formatted profile sections lose their structure when exported.
  • Paragraphs and manual line breaks are flattened into a single block of text.
  • Tools that consume the exported data cannot reconstruct the original formatting reliably.

A practical workaround is to encode line breaks using Markdown-style "two spaces at the end of a line". These trailing spaces survive LinkedIn's processing and can be used later to restore line breaks in downstream tools (for example, parsers built around linkedin-mdp-api).

This script automates that workaround: it ensures that, right before you save an edit on LinkedIn, every line/paragraph in relevant fields ends with exactly two spaces.


What is a Userscript and how to install one

A Userscript is a small piece of JavaScript that runs in your browser and modifies the behavior or appearance of specific websites locally. It is managed through a browser extension called a userscript manager.

To use this script, you need to install a userscript manager extension, such as: Tampermonkey, Violentmonkey, Greasemonkey.

Installation

  1. Install one of the extensions above.
  2. Open the raw script file: linkedin_eol_formatter.user.js
  3. The extension will automatically detect it and show an install dialog.
  4. Click Install.

The script will now activate automatically when you open any LinkedIn profile edit form.


What the script does

  1. Watches the page for LinkedIn edit dialogs to appear.
  2. When a dialog is opened, it finds the Save button and hooks the button's click event.
  3. When you click Save, the script instantly scans all edit fields in the dialog, strips any trailing whitespace and appends exactly two spaces to the end of every paragraph/line.

Security & Privacy (Safety check)

This script is intentionally simple, transparent, and local-only. You are encouraged to review its source code to verify its safety.

By inspecting the code, you will see that:

  • It does not send any requests to external servers (no fetch() or XMLHttpRequest).
  • It does not read from or write to localStorage, cookies, or any persistent storage.
  • It does not contain any tracking or analytics.
  • It only observes the DOM and dispatches local browser events to format your text.

All processing happens entirely within your browser on your machine.

Developer Notes

This file documents the technical nuances, constraints, and decisions made during the development of the LinkedIn Editor Space Formatter userscript.

These notes are intended to understand why certain implementation choices were made — particularly where LinkedIn's internals are opaque and decisions were made out of caution rather than full certainty.


1. DOM changes paired with synthetic events

The script modifies editor content directly in the DOM — setting p.innerHTML for contenteditable fields and writing via the native value setter for <textarea> elements (see section 2). After each modification, it dispatches input, change, and blur events with { bubbles: true }.

The reason: LinkedIn's edit fields bear DOM properties associated with Tiptap and ProseMirror — it is unclear whether those exact libraries are in use, or something derived from them. Regardless, the presence of those markers suggests a framework that maintains its own internal document state. Dispatching events after a DOM change is a standard way to signal that something has changed; without it, the framework may never pick up the modification. We haven't verified what the minimum sufficient set of events is — all three are dispatched out of caution.

2. Writing to <textarea> via the native prototype setter

For <textarea> elements the script does not assign ta.value = newValue directly. Instead it retrieves the native setter from HTMLTextAreaElement.prototype and calls it explicitly:

const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
  window.HTMLTextAreaElement.prototype, "value"
).set;
if (nativeInputValueSetter) {
  nativeInputValueSetter.call(ta, newValue);
} else {
  ta.value = newValue; // fallback
}

The concern is that React (and similar frameworks) are known to override the .value setter on form elements to track state internally. A direct assignment in that case would be invisible to the framework even if the DOM reflects the new value. Calling the native prototype setter bypasses any such override. The fallback to direct assignment is kept for environments where the native descriptor is unavailable. Whether LinkedIn's <textarea> fields actually use such an override has not been confirmed — this is a precautionary measure.

3. Hooking the Save button in the capture phase

The click listener on the Save button is registered with true as the third argument (useCapture):

saveButton.addEventListener('click', formatEditors, true);

The capture phase runs before the bubbling phase, which means the formatter executes before any listeners the page itself may have attached in the bubbling phase. The motivation is to ensure the text is already formatted at the moment LinkedIn's own handler reads the field contents to submit the form. Alternative approaches (e.g. hooking earlier in the dialog lifecycle, or intercepting the network request) were not explored.

4. Guarding against iframe injection

LinkedIn embeds multiple hidden <iframe> elements on the page (ads, analytics, tracking). Without guards, the userscript initializes inside each of them — observable as the Observer started... log appearing 4+ times on page load.

Two measures are in place, both verified to be effective:

  1. // @noframes directive in the Tampermonkey metadata block — prevents the manager from injecting the script into frames at all.
  2. Runtime guard at the top of the script: if (window.top !== window.self) return; — provides a defence-in-depth fallback in case the metadata directive is not respected by the userscript manager.
// ==UserScript==
// @name LinkedIn Editor Space Formatter
// @namespace https://github.com/foxdark410/
// @version 0.1
// @description Ensures every paragraph in LinkedIn edit forms ends with exactly two spaces to preserve line breaks in data exports.
// @author You
// @match *://*.linkedin.com/*
// @noframes
// @updateURL https://raw.githubusercontent.com/foxdark410/linkedin-mdp-api/main/linkedin_formatter.user.js
// @downloadURL https://raw.githubusercontent.com/foxdark410/linkedin-mdp-api/main/linkedin_formatter.user.js
// @grant none
// ==/UserScript==
(function() {
'use strict';
if (window.top !== window.self) return;
function formatEditors() {
console.log('[LinkedIn Space Formatter] Formatting triggered by Save button click.');
const editors = document.querySelectorAll('div[contenteditable="true"].tiptap.ProseMirror');
const textareas = document.querySelectorAll('textarea');
if (editors.length === 0 && textareas.length === 0) {
console.log('No textarea/Tiptap editor found!');
return;
}
console.log('Processing:');
editors.forEach((editor) => {
const paragraphs = editor.querySelectorAll('p');
let changedParagraphs = 0;
paragraphs.forEach(p => {
const originalHtml = p.innerHTML;
const regex = /(?:\s|&nbsp;)*(<br[^>]*>)?$/i;
const newHtml = originalHtml.replace(regex, ' $1');
if (originalHtml !== newHtml) {
p.innerHTML = newHtml;
changedParagraphs++;
}
});
if (changedParagraphs > 0) {
console.log(`- Tiptap editor: ${changedParagraphs} paragraph(s) changed`);
editor.dispatchEvent(new Event('input', { bubbles: true, cancelable: true }));
editor.dispatchEvent(new Event('change', { bubbles: true, cancelable: true }));
editor.dispatchEvent(new Event('blur', { bubbles: true, cancelable: true }));
} else {
console.log('- Tiptap editor: unchanged');
}
});
textareas.forEach((ta) => {
const originalValue = ta.value;
if (!originalValue) {
console.log('- textarea: unchanged (empty)');
return;
}
let changedLines = 0;
const lines = originalValue.split('\n');
const newLines = lines.map(line => {
const replaced = line.replace(/[\s\u00A0]+$/, '') + ' ';
if (replaced !== line) {
changedLines++;
}
return replaced;
});
const newValue = newLines.join('\n');
if (changedLines > 0) {
console.log(`- textarea: ${changedLines} line(s) changed`);
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value").set;
if (nativeInputValueSetter) {
nativeInputValueSetter.call(ta, newValue);
} else {
ta.value = newValue;
}
ta.dispatchEvent(new Event('input', { bubbles: true, cancelable: true }));
ta.dispatchEvent(new Event('change', { bubbles: true, cancelable: true }));
ta.dispatchEvent(new Event('blur', { bubbles: true, cancelable: true }));
} else {
console.log('- textarea: unchanged');
}
});
}
function tryHookSaveButton(dialog) {
const buttons = Array.from(dialog.querySelectorAll('button'));
const saveButton = buttons.find(b => b.textContent.trim().toLowerCase() === 'save');
if (saveButton && !saveButton.dataset.spacesHooked) {
saveButton.dataset.spacesHooked = "true";
saveButton.addEventListener('click', () => {
formatEditors();
}, true);
console.log('[LinkedIn Space Formatter] Save button hooked successfully.');
}
}
const observer = new MutationObserver(() => {
const dialog = document.querySelector('dialog[open]');
if (dialog) {
tryHookSaveButton(dialog);
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['open']
});
console.log('[LinkedIn Space Formatter] Observer started. Waiting for dialogs...');
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment