Skip to content

Repository files navigation

md2gd

CI Version License

Convert a Markdown file into a professionally styled Google Doc from the command line.

md2gd ~/notes/report.md
# → https://docs.google.com/document/d/…/edit

One command turns a Markdown file into a cleanly styled Google Doc in your Drive — proper heading hierarchy, readable spacing, styled tables with padded cells and a shaded header row, monospace code, and working links. The document never leaves your own Google account.

The bundled examples/sample.md rendered as a Google Doc by md2gd

The bundled examples/sample.md rendered by md2gd.

Why

I write long-form reports in Markdown, then need them as polished, shareable Google Docs. That last step is the slow one. Pasting or exporting through HTML mangles exactly what matters in a report: tables lose their structure, spacing collapses, code and headings need reflowing. You end up fixing the document by hand instead of writing it.

md2gd removes that step. One command, the same clean styling every time, and the file never leaves your own Drive.

Requirements

  • macOS (Apple Silicon) or Linux (x64) — Intel Macs need to build from source
  • A Google account

The prebuilt binary bundles its runtime, so that's all you need to run md2gd.

Building from source additionally needs Bun 1.3+.

Install

Prebuilt binary

curl -fsSL https://raw.githubusercontent.com/cniska/md2gd/main/scripts/install.sh | sh

Downloads the latest release binary for your platform into ~/.local/bin and verifies its checksum — no Bun required. (Available once a release is published.)

To update, re-run the same command — it always fetches the latest release.

From source

Requires Bun 1.3+.

git clone https://github.com/cniska/md2gd.git
cd md2gd
bun install
bun link        # exposes `md2gd` on your PATH

You can also run it without linking via bun run src/cli.ts <file.md>.

Once set up (see below), try it on the bundled sample: md2gd examples/sample.md.

One-time Google setup

Creating a Doc in your Drive is a per-user write, so Google requires an OAuth login — there is no API-key shortcut. This is a one-time, ~5-minute setup.

  1. Open the Google Cloud Console and create (or pick) a project.
  2. Enable two APIs for the project: Google Docs API and Google Drive API (APIs & Services → Library).
  3. Configure the OAuth consent screen: user type External, fill in the required fields, and publish it to Production. Leaving it in "Testing" makes Google expire your login after 7 days.
  4. Under Credentials → Create credentials → OAuth client ID, choose application type Desktop app. Download the resulting client_secret.json.

md2gd uses the drive scope so it can place docs in folders you choose — including shared folders you didn't create (--folder) — and update docs it didn't originally create. Because that's a sensitive scope, the consent screen shows an "unverified app" warning; click Advanced → Go to md2gd (unsafe) to proceed. That's expected for a personal tool running against your own account with code you can read.

Authenticate

Run once, pointing at the file you downloaded:

md2gd init --client ~/Downloads/client_secret.json

Your browser opens for consent. Approve it, and the token is cached locally. After this, conversion is pure command-line — the access token refreshes silently.

Upgrading from an earlier version? The OAuth scope changed, so re-run md2gd init to re-consent (delete token.json first if it doesn't re-prompt — see Reset).

Usage

md2gd <file.md> [--title <title>] [--folder <url|id>] [--links <map.json>] [--open]
md2gd <file.md> --update [<url|id>] [--title <title>] [--links <map.json>] [--open]
  • --title <title> — override the document title (defaults to the file's top # H1, else its title-cased filename, e.g. service-readiness-review.md → "Service Readiness Review").
  • --folder <url|id> — the destination Drive folder (a folder URL or bare id). On a new conversion it places the doc there instead of the default md2gd folder; with --update it moves the existing doc into that folder (same URL).
  • --links <map.json> — turn relative links between docs into live Doc links (see below).
  • --update [<url|id>] — re-render into an existing doc instead of creating a new one (see below).
  • --open — open the doc in your browser afterwards.
  • md2gd --help / md2gd --version.

By default each run creates a new document in an md2gd folder in your Drive and prints its URL. Pass --folder to place it elsewhere, including a shared folder:

md2gd docs/schema.md --folder https://drive.google.com/drive/folders/1QzE1-xPWzbF…

Cross-document links

When you mirror a set of Markdown docs to Google Docs, the relative links between them ([reference](reference/api.md), [guide](../guide.md)) would be dead in the output — a Doc can't follow a .md path — so md2gd renders them as plain text. Pass --links <map.json> to turn any such link whose target is also a mapped doc into a live hyperlink to that doc's Google Doc:

md2gd guide.md --update --links links.json

The map is a JSON object mapping each document's path to its Google Doc id:

{
  "guide.md": "1AbCdEfGhIjKlMnOpQrStUvWxYz",
  "reference/api.md": "1ZyXwVuTsRqPoNmLkJiHgFeDcBa"
}
  • Keys resolve relative to the map file's own location, so the map is portable and independent of where you run md2gd from.
  • A link's target resolves relative to the source Markdown file (matching how Markdown renderers work), so reference/api.md from a root-level guide.md, and ../guide.md from reference/api.md, both find their entry.
  • Only the link target changes — the visible link text is never touched.
  • A #heading fragment on a matched link is dropped (a Doc URL can't address a Markdown heading). Links to docs not in the map, in-page anchors, and normal https:// links are left exactly as they are.

A map value may also be a full Docs URL — a bare id is just cleaner. md2gd prints a one-line summary to stderr (e.g. md2gd: 9 cross-links linked, 4 unmatched), so it never interferes with the doc URL on stdout.

Updating a doc in place (stable URL)

The usual loop is edit the Markdown, regenerate the Doc. --update re-renders into the same document so its URL, Drive location, and any shares stay put:

md2gd ~/notes/report.md            # first run — creates the doc, remembers it
md2gd ~/notes/report.md --update   # re-renders into that same doc, same URL

With no argument, --update targets the doc previously created from — or last updated for — that file (remembered in config.json). Pass an explicit target — a full Docs URL or a bare id — to override, or to adopt an existing doc: the first --update <url|id> binds that file to that doc (even one you made by hand), so afterwards a plain --update finds it automatically:

md2gd report.md --update https://docs.google.com/document/d/1AbC…/edit

A plain run (no --update) never overwrites: if a doc already exists for the file, md2gd still creates a new one and prints a reminder that --update would overwrite instead.

Limits, by design:

  • --update works on any doc you can edit — one md2gd made, or one you created by hand or that was shared into a folder. A plain run still never overwrites (it always creates and prints a reminder); overwriting is only ever explicit via --update. A target you can't access fails with a clear message, changing nothing.
  • Updating clears and rewrites the body. Google Docs comments anchored to the old content will orphan, and the rewrite is not atomic — an interrupted run can leave the doc partially rewritten. For the regenerate-a-report loop this is the right trade; for a heavily commented doc, prefer a fresh conversion.

What it renders

Headings, bold/italic/strikethrough, inline code and fenced code blocks, links, ordered/unordered/nested and task lists, blockquotes, and tables (with sized columns, padded cells, and a shaded header row). Emoji and non-ASCII text are preserved. Horizontal rules (---) are intentionally ignored — heading spacing already separates sections.

Not yet supported (they degrade to readable text): images (rendered as their alt text), footnotes, and per-level markers for mixed-type nested lists.

Configuration and credentials

md2gd stores everything in a user-scoped directory with owner-only permissions — ~/.md2gd/ on macOS, and $XDG_CONFIG_HOME/md2gd (default ~/.config/md2gd) on Linux:

  • client_secret.json — your OAuth client (copied in by init)
  • token.json — the cached access/refresh token
  • config.json — remembers which doc was created from each file, for --update

Nothing here is ever transmitted anywhere except Google's own APIs.

Reset

To sign out, delete the cached token and re-run init; delete the whole directory for a full reset including the stored client secret:

# macOS
rm ~/.md2gd/token.json         # re-authenticate on next `md2gd init`
rm -rf ~/.md2gd                # full reset

# Linux
rm ~/.config/md2gd/token.json  # re-authenticate on next `md2gd init`
rm -rf ~/.config/md2gd         # full reset

Contributing

Contributions are welcome — see CONTRIBUTING.md. Run bun run verify before every commit.

License

MIT © Christoffer Niska

About

Convert Markdown into a professionally styled Google Doc from the command line.

Resources

Contributing

Stars

Watchers

Forks

Releases

Contributors

Languages