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 by md2gd.
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.
- 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+.
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.
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.
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.
- Open the Google Cloud Console and create (or pick) a project.
- Enable two APIs for the project: Google Docs API and Google Drive API (APIs & Services → Library).
- 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.
- 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.
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).
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 defaultmd2gdfolder; with--updateit 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…
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.mdfrom a root-levelguide.md, and../guide.mdfromreference/api.md, both find their entry. - Only the link target changes — the visible link text is never touched.
- A
#headingfragment 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 normalhttps://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.
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:
--updateworks 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.
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.
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 byinit)token.json— the cached access/refresh tokenconfig.json— remembers which doc was created from each file, for--update
Nothing here is ever transmitted anywhere except Google's own APIs.
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
Contributions are welcome — see CONTRIBUTING.md. Run bun run verify before every commit.
MIT © Christoffer Niska
