1. Architecture Overview
Markdown Here is a browser extension (WebExtension API, Manifest V3) for Chrome and Firefox. It renders Markdown typed in rich-text contenteditable fields into styled HTML in-place, without any server component. All rendering happens synchronously inside the browser using a bundled fork of marked.js and highlight.js. Settings are persisted via chrome.storage.sync.
User action (Alt+Shift+M / toolbar / context menu)
│
▼
backgroundscript.js (service worker / background page)
│ handleActionClick()
│ ├── Injector.injectScripts(tabId) ← injects content scripts on demand
│ └── chrome.tabs.sendMessage({action: 'button-click'})
│
▼
contentscript.js (injected into active tab)
│ requestHandler() → markdownHere(document, requestMarkdownConversion, ...)
│
▼
markdown-here.js (core toggle logic)
│ ├── findFocusedElem() — drill into iframes
│ ├── elementCanBeRendered()
│ ├── getOperationalRange() — selection or full element
│ ├── findMarkdownHereWrapper() — detect existing render → revert path
│ └── render path:
│ requestMarkdownConversion(elem, range, callback)
│ │
│ ▼
│ MdhHtmlToText.get() — extract plain text from HTML field
│ Utils.makeRequestToPrivilegedScript({action:'render', mdText})
│ │
│ ▼
│ backgroundscript.js: MarkdownRender.markdownRender(mdText, prefs, marked, hljs)
│ → marked(mdText, markedOptions) + optional mathify()
│ → returns {html, css}
│ │
│ ▼
│ mdhHtmlToText.postprocess(html)
│ makeStylesExplicit(wrapperElem, css) — inline CSS rules
│ replaceRange(range, html) — write rendered HTML into DOM
Note
Rendering is performed in the background script (service worker in Chrome MV3, background page in Firefox MV2/MV3), not in the content script. This architectural choice prevents third-party page scripts from accessing the rendering pipeline. The content script communicates via
chrome.runtime.sendMessage, and only the sanitized HTML result is returned.
Component inventory
| Component | Path | Role |
|---|---|---|
| Manifest | src/manifest.json |
Extension declaration, permissions, keyboard commands |
| Background script | src/chrome/backgroundscript.js |
Service worker / background page; handles all rendering |
| Content script | src/chrome/contentscript.js |
Injected into pages; handles toggle events, forgot-to-render interval |
| Core toggle logic | src/common/markdown-here.js |
Render/revert state machine, DOM manipulation |
| Markdown renderer | src/common/markdown-render.js |
marked.js wrapper with MDH customizations |
| marked.js fork | src/common/marked.js |
Bundled Markdown parser (GFM + math extension) |
| highlight.js | src/common/highlightjs/highlight.js |
Syntax highlighting engine |
| Highlight themes | src/common/highlightjs/styles/ |
100+ CSS theme files |
| Options store | src/common/options-store.js |
Chrome storage abstraction; defaults; chunked-string handling |
| Options UI | src/common/options.js / options.html |
Settings page logic and markup |
| Options iframe | src/common/options-iframe.js / .html |
Live preview iframe embedded in Options |
| Common logic | src/common/common-logic.js |
Forgot-to-render detection and prompt logic |
| HTML-to-text | src/common/mdh-html-to-text.js |
Extracts plain text from contenteditable HTML for rendering |
| HTML-to-text (base) | src/common/jsHtmlToText.js |
Google-derived HTML-to-text utility |
| Content permissions | src/common/content-permissions.js |
Manages optional host permissions (chrome.permissions API) |
| Upgrade popup | src/chrome/upgrade-notification-popup.js / .html |
Post-update notification shown in toolbar |
| Utils | src/common/utils.js |
Shared helpers: messaging, DOM safety, locale strings |
| Default CSS | src/common/default.css |
Built-in stylesheet for rendered Markdown |
| Locales | src/_locales/ |
i18n message files (13 languages) |
| Build script | utils/build.js |
Node.js script producing dist/chrome.zip and dist/firefox.zip |
Technology stack
| Layer | Technology |
|---|---|
| Extension API | WebExtension (Manifest V3 for Chrome; MV3 / MV2 hybrid for Firefox) |
| Markdown parsing | Custom fork of marked.js (bundled; GFM + math extension) |
| Syntax highlighting | highlight.js (bundled) |
| HTML sanitization | DOMPurify (bundled, src/common/vendor/dompurify.min.js) |
| Storage | chrome.storage.sync (falls back to localStorage) |
| Build tooling | Node.js, npm, archiver, file, glob |
| CSS | Plain CSS (no preprocessor) |
| i18n | WebExtension _locales/ + chrome.i18n.getMessage() |
2. System Requirements
Runtime (users)
| Requirement | Detail |
|---|---|
| Browser | Chrome (desktop) or Firefox (desktop) |
| Internet | Optional — required only for LaTeX math rendering (codecogs.com) |
| Permissions granted | activeTab, contextMenus, storage, scripting |
| Optional permissions | http://*/*, https://*/* (needed for forgot-to-render on arbitrary sites) |
Development / build
| Requirement | Detail |
|---|---|
| Node.js | Any modern LTS version |
| npm | Included with Node.js |
| OS | Any (macOS, Windows, Linux) |
3. Configuration Reference
All settings are stored in chrome.storage.sync. They are managed through the Options page UI and read by both the background script and the content script (via messaging).
Options keys and defaults
| Key | Type | Default | Description |
|---|---|---|---|
main-css |
string (CSS) | Content of default.css |
Full CSS stylesheet applied to rendered Markdown output. |
syntax-css |
string (CSS) | Content of highlightjs/styles/github.css |
CSS theme for code block syntax highlighting. |
math-enabled |
boolean | false |
Enable LaTeX math rendering via codecogs.com. |
math-value |
string (URL template) | See below | URL template for rendering math expressions. |
forgot-to-render-check-enabled-2 |
boolean | false |
Prompt user before sending if unrendered Markdown detected. |
header-anchors-enabled |
boolean | false |
Add id anchors to headings for in-page linking. |
gfm-line-breaks-enabled |
boolean | true |
Single newlines become <br>. |
Default math-value template:
<img src="https://latex.codecogs.com/png.image?\dpi{120}\inline&space;{urlmathcode}" alt="{mathcode}">
Where {urlmathcode} is the URL-encoded LaTeX expression and {mathcode} is the raw LaTeX (for the alt text).
Important
The
main-cssandsyntax-cssvalues are loaded from local extension files on first access (using__defaultFromFile__markers inOptionsStore.defaults). They are fetched once and stored, so the options object always contains the full CSS text, not a file reference. The chunked storage system (##0,##1key suffixes) handles values that exceedchrome.storage.sync.QUOTA_BYTES_PER_ITEM.
Storage chunking
Long string values (the CSS fields) exceed chrome.storage.sync.QUOTA_BYTES_PER_ITEM (8192 bytes). OptionsStore automatically splits strings into numbered chunks using ## as a separator (e.g., main-css##0, main-css##1) and recombines them on read.
Permissions model
The extension uses optional host permissions (http://*/*, https://*/*). These are not granted at install time. They are requested dynamically by ContentPermissions.requestPermission() when:
- The forgot-to-render feature is enabled (requests permission for the specific domain)
- The user activates the extension on a page where the auto-inject path needs confirmation
The forgot-to-render feature specifically targets mail.google.com (Gmail) and fastmail.com. The button-click path uses activeTab permission, which is always granted, and injects scripts on demand without requiring broad host permissions.
4. Deployment & Build
Building from source
# Clone the repository
git clone https://github.com/adam-p/markdown-here.git
cd markdown-here/utils
# Install build dependencies
npm install
# Build both extension packages
node build.js
dist/chrome.zip ← Load in Chrome via chrome://extensions → Load unpacked (extract first)
dist/firefox.zip ← Submit to AMO or load via about:debugging
Note
The build script strips Firefox-specific fields from
manifest.jsonwhen producing the Chrome package. Specifically the"browser_specific_settings"block with the Firefox geckoidis removed for Chrome builds. Both packages share the samesrc/source — there is no platform-specific code other than the background page / service worker dual-path inbackgroundscript.js.
Loading the unpacked extension (development)
Chrome
- Extract
dist/chrome.zip(or point directly atsrc/). - Navigate to
chrome://extensions. - Enable Developer mode (toggle top right).
- Click Load unpacked and select the directory.
Firefox
- Navigate to
about:debugging#/runtime/this-firefox. - Click Load Temporary Add-on.
- Select
dist/firefox.ziporsrc/manifest.json.
Content script injection order
The background script injects content scripts in this exact order when a tab is activated:
1. /common/vendor/dompurify.min.js
2. /common/utils.js
3. /common/common-logic.js
4. /common/jsHtmlToText.js
5. /common/marked.js
6. /common/mdh-html-to-text.js
7. /common/markdown-here.js
8. /chrome/contentscript.js
The Injector.checkAndMarkInjected() function uses window.markdownHereInjected as a per-tab flag to prevent double-injection.
5. Operations
Service worker lifecycle (Chrome MV3)
In Chrome, the background script runs as a service worker. Service workers are torn down after ~30 seconds of inactivity and revived on new extension events. This means:
- Global variables cannot store state between activations.
- The
backgroundPageboolean (set from the URL hash) determines whether the script is running as a legacy background page (Firefox) or a service worker (Chrome MV3). - When running as a service worker, scripts must be loaded via
importScripts()on each activation.
Warning
Do not store per-tab state in global variables in
backgroundscript.js. The service worker may be torn down and restarted between the inject call and the message handler call for a given tab interaction.
Background page vs. service worker
| Mode | Browser | Lifetime | Script loading |
|---|---|---|---|
| Service worker | Chrome MV3 | ~30s idle timeout | importScripts() on each load |
| Background page | Firefox MV2/MV3 | Session lifetime | Scripts declared in background.page HTML |
The background.html page is used for the background-page path; backgroundscript.js checks !!location.hash at startup to detect which mode it is in.
Auto-injection on tab load
The chrome.tabs.onUpdated listener auto-injects content scripts into tabs that have already been granted host permission (via ContentPermissions.hasPermission(tab.url)). This allows the forgot-to-render check to run before the user activates the extension — it polls every 2 seconds via setInterval(forgotToRenderCheck, 2000).
Context menu registration
The context menu item is created once via chrome.contextMenus.create() inside chrome.runtime.onInstalled. It is only created on install or update events to avoid duplicate entries.
6. Security
Input sanitization
All HTML produced by marked.js rendering passes through DOMPurify before being inserted into the DOM. The content script calls Utils.saferSetOuterHTML() and Utils.safelyParseHTML(), both of which use DOMPurify internally to strip dangerous attributes and elements.
Caution
An XSS vulnerability was reported in the bundled marked.js and rendering pipeline (see #978). The DOMPurify integration is the primary mitigation. Ensure the vendored
dompurify.min.jsis kept up to date when building from source.
Rendering pipeline isolation
Rendering is intentionally executed in the privileged background script, not in the content script. The content script sends raw Markdown text to the background via chrome.runtime.sendMessage and receives only the sanitized HTML result. This prevents injected page scripts from intercepting the rendering logic or the marked/hljs libraries.
MDH wrapper persistence
The original Markdown source is saved in the title attribute of a hidden <div> inserted as a child of the rendered element, prefixed with MDH:. This div has no visible size. The choice of <div title> was made because it survives round-trips through most webmail client DOM manipulations (documented in markdown-here.js comments and GitHub issue #85).
Optional host permissions
Broad host permissions (http://*/*, https://*/*) are optional — never granted at install. The activeTab permission is sufficient for the primary use case (user-triggered render). Optional permissions are only requested for the forgot-to-render automatic check, which must observe send button clicks on known domains.
External requests
| Feature | External endpoint | Condition |
|---|---|---|
| Math rendering | https://latex.codecogs.com/png.image?... |
Only when math-enabled is true and content contains $...$ or $$...$$ |
| All other features | None | No external requests |
Warning
Math rendering sends LaTeX expression text to an external service (codecogs.com). Users who work with sensitive content in emails should be aware of this and can leave math rendering disabled (the default).
7. Developer Internals
Repository layout
markdown-here/
├── src/
│ ├── manifest.json Extension manifest (v3, shared Chrome/Firefox)
│ ├── chrome/
│ │ ├── backgroundscript.js Service worker / background page
│ │ ├── background.html Background page loader (Firefox / legacy)
│ │ ├── contentscript.js Per-tab content script
│ │ ├── upgrade-notification-popup.html
│ │ └── upgrade-notification-popup.js
│ ├── common/
│ │ ├── markdown-here.js Core render/revert toggle logic
│ │ ├── markdown-render.js marked.js wrapper + math + hljs integration
│ │ ├── marked.js Bundled Markdown parser (GFM fork)
│ │ ├── options-store.js Storage abstraction + defaults
│ │ ├── options.js / .html Options page
│ │ ├── options-iframe.js / .html Live preview iframe
│ │ ├── common-logic.js Forgot-to-render detection/prompt
│ │ ├── content-permissions.js chrome.permissions wrapper
│ │ ├── utils.js Shared utilities
│ │ ├── default.css Default render stylesheet
│ │ ├── jsHtmlToText.js Google HTML-to-text utility
│ │ ├── mdh-html-to-text.js MDH-specific HTML-to-text (pre-render extraction)
│ │ ├── forgot-to-render-prompt.html Prompt UI template
│ │ ├── vendor/
│ │ │ └── dompurify.min.js DOMPurify HTML sanitizer
│ │ └── highlightjs/
│ │ ├── highlight.js Syntax highlighting engine
│ │ └── styles/ 100+ CSS theme files
│ └── _locales/ i18n messages (de, en, es, fr, it, ja, ko, pl, pt_BR, ru, tr, zh_CN, zh_TW)
├── utils/
│ ├── build.js Build script (produces dist/chrome.zip, dist/firefox.zip)
│ └── package.json Build dependencies
├── test/ Top-level test harness entry
├── src/common/test/ Unit tests (mocha + chai, run in browser)
├── Makefile
├── CHANGES.md
├── CONTRIBUTING.md
└── PRIVACY.md
Rendering pipeline internals
Pre-render extraction (mdh-html-to-text.js + jsHtmlToText.js):
The content script extracts plain text from the HTML currently in the contenteditable field before sending it to the background for rendering. This is necessary because web clients store field content as HTML (with formatting from previous pastes, etc.), not plain text. The extractor strips HTML tags while preserving newlines and whitespace meaningful to Markdown.
marked.js customizations (markdown-render.js):
The bundled marked.js fork has several MDH-specific modifications:
- Heading IDs — sanitized with
sanitizeLinkForAnchor()whenheader-anchors-enabledis true. - Link renderer override — prepends
https://to schemeless URLs. - Math support —
userprefs['math-enabled']injects amathify()function asmarkedOptions.math, which substitutes$...$and$$...$$with<img>tags pointing to codecogs.com. - highlight.js integration —
markedOptions.highlightcallback callshljs.highlight()with the detected language, prefixing the class withhljs language-for CSS targeting. - Always-on options:
gfm: true,tables: true,smartLists: true,smartypants: true,pedantic: false.
Post-render style inlining (markdown-here.js → makeStylesExplicit):
After marked.js produces HTML, makeStylesExplicit() inlines all CSS rules from main-css and syntax-css as style="" attributes on every matching element. This is required because:
- Most webmail clients strip
<style>tags and<link>elements when the email is sent. - Inline styles survive the stripping and arrive at the recipient.
Forgotten-to-render detection heuristics
CommonLogic.probablyWritingMarkdown() runs on a ≤10KB excerpt and returns true if any of these patterns match:
| Pattern | Detection |
|---|---|
| Bullet list | ≥2 lines starting with * , + , or - |
| Backticks | Any backtick character |
| Math | $...$ pattern (not just dollar signs at start/end of words) |
| Bold/strong emphasis | **...** or __...__ |
| Headers | ## through ###### , or underlines of ≥5 = or - |
| Links | ]( or ][ patterns |
Single-asterisk emphasis is intentionally excluded to reduce false positives in ordinary text.
Code patterns
- Module pattern: All common scripts use IIFEs exporting via
module.exports(Node.js) orthis.Symbol = Symbol(browser global). This allows the same files to be used in tests (CommonJS require) and in the extension (global namespace). - Messaging: Content scripts never call the rendering logic directly; they send messages via
Utils.makeRequestToPrivilegedScript()which wrapschrome.runtime.sendMessage()with a promise-style callback. - DOM safety: All HTML insertion goes through
Utils.saferSetOuterHTML()orUtils.safelyParseHTML()(DOMPurify wrappers). DirectinnerHTMLassignment is not used in user-facing paths. - Style attribute merging:
makeStylesExplicit()appends inlined styles to any existingstyle=""value and ensures trailing semicolons are present before appending, preventing malformed style strings.
Testing
Tests are browser-based (run in a test HTML page) using mocha and chai, located in src/common/test/. Test files:
| File | Coverage |
|---|---|
markdown-here-test.js |
Core render/revert toggle logic |
markdown-render-test.js |
Marked.js rendering output |
mdh-html-to-text-test.js |
HTML-to-text extraction |
options-store-test.js |
Storage abstraction |
common-logic-test.js |
Forgot-to-render heuristics |
utils-test.js |
Shared utilities |
To run tests: open src/common/test/index.html (or test/index.html) in a browser with the extension installed, or use the test runner script.
Contributing
See CONTRIBUTING.md in the repository root. The project uses a CLA (Contributor License Agreement); both individual and entity CLAs are available in CLA-individual.md and CLA-entity.md. Contributors are listed in contributors/. The project follows the standard JavaScript style (enforced via the ESLint config in .eslintrc.json).
Note
The
.eslintrc.jsonenforces strict single-quote strings, 2-space indent,stroustrupbrace style, and disallowseval, prototype extension, and implied eval. The project targets ES6+ syntax with CommonJS module support.
8. Integration Points
Supported email clients and web apps
The extension works on any contenteditable rich-text field. The following clients are specifically tested or referenced in the source:
| Client | Notes |
|---|---|
| Gmail | Primary target. Forgot-to-render supported. Signature detection via [role="button"][tabindex="1"][aria-label][data-tooltip] selector. |
| Fastmail | Forgot-to-render supported via [class~="s-send"] selector. |
| Other webmail | Should work if they use contenteditable rich-text compose fields. |
| Google Docs | Not supported — does not use standard contenteditable. |
| Thunderbird | Historically supported via XPI; current build script references Thunderbird but notes it as TODO. |
i18n
The extension is localized for 13 languages via the standard WebExtension _locales/ mechanism. All user-visible strings in JavaScript are retrieved via Utils.getMessage() which wraps chrome.i18n.getMessage(). The manifest uses __MSG_app_name__ and __MSG_app_slogan__ message keys.
Supported locales: de, en, es, fr, it, ja, ko, pl, pt_BR, ru, tr, zh_CN, zh_TW.