Jesse A. Green / notebook

Live demo: the DocCompiler guide library as a static, read-only snapshot (October 2026). No backend, no accounts, nothing to submit.

Markdown Here — Administrator & Developer Guide

Generated May 2026 from adam-p/markdown-here at e00d005

AI-generated documentation. The library is checked by sampling (three repositories so far); this guide was not individually reviewed. Verify against the project before relying on it.

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-css and syntax-css values are loaded from local extension files on first access (using __defaultFromFile__ markers in OptionsStore.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, ##1 key suffixes) handles values that exceed chrome.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.json when producing the Chrome package. Specifically the "browser_specific_settings" block with the Firefox gecko id is removed for Chrome builds. Both packages share the same src/ source — there is no platform-specific code other than the background page / service worker dual-path in backgroundscript.js.

Loading the unpacked extension (development)

Chrome

  1. Extract dist/chrome.zip (or point directly at src/).
  2. Navigate to chrome://extensions.
  3. Enable Developer mode (toggle top right).
  4. Click Load unpacked and select the directory.

Firefox

  1. Navigate to about:debugging#/runtime/this-firefox.
  2. Click Load Temporary Add-on.
  3. Select dist/firefox.zip or src/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 backgroundPage boolean (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.js is 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() when header-anchors-enabled is true.
  • Link renderer override — prepends https:// to schemeless URLs.
  • Math support — userprefs['math-enabled'] injects a mathify() function as markedOptions.math, which substitutes $...$ and $$...$$ with <img> tags pointing to codecogs.com.
  • highlight.js integration — markedOptions.highlight callback calls hljs.highlight() with the detected language, prefixing the class with hljs 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) or this.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 wraps chrome.runtime.sendMessage() with a promise-style callback.
  • DOM safety: All HTML insertion goes through Utils.saferSetOuterHTML() or Utils.safelyParseHTML() (DOMPurify wrappers). Direct innerHTML assignment is not used in user-facing paths.
  • Style attribute merging: makeStylesExplicit() appends inlined styles to any existing style="" 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.json enforces strict single-quote strings, 2-space indent, stroustrup brace style, and disallows eval, 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.