Skip to content

Instantly share code, notes, and snippets.

@htlin222
Last active August 27, 2025 11:42
Show Gist options
  • Save htlin222/ff736b45c047075cadbeb326448fec26 to your computer and use it in GitHub Desktop.
Save htlin222/ff736b45c047075cadbeb326448fec26 to your computer and use it in GitHub Desktop.
Google Docs Apps Script | Markdown-Style Heading & Bold Converter
/**
* Adds a menu to run the converter.
*/
function onOpen() {
DocumentApp.getUi()
.createMenu('Markdown')
.addItem('Convert #, **, and lists', 'convertMarkdownHeadingsAndBold')
.addToUi();
}
/**
* Main entry: parse headings first, then clean, then lists, then bold.
*/
function convertMarkdownHeadingsAndBold() {
const doc = DocumentApp.getActiveDocument();
const body = doc.getBody();
convertHashHeadings_(body); // Step 1: # -> Heading 1–6
removeEmptyLineAfterHeadings_(body); // Step 2: remove blank line after headings
convertMarkdownLists_(body); // NEW: Step 3: parse 1./*/- /+ lists (with nesting)
convertDoubleAsteriskBold_(body); // Step 4: **...** -> bold
}
/**
* Convert lines starting with 1–6 hashes into Heading 1–6,
* and strip the leading "#... " marker.
*/
function convertHashHeadings_(body) {
const paragraphs = body.getParagraphs();
for (let p of paragraphs) {
const text = p.getText();
if (!text) continue;
const m = text.match(/^(#{1,6})\s+(.*)$/);
if (!m) continue;
const level = Math.min(m[1].length, 6);
const headingEnum = [
null,
DocumentApp.ParagraphHeading.HEADING1,
DocumentApp.ParagraphHeading.HEADING2,
DocumentApp.ParagraphHeading.HEADING3,
DocumentApp.ParagraphHeading.HEADING4,
DocumentApp.ParagraphHeading.HEADING5,
DocumentApp.ParagraphHeading.HEADING6,
][level];
p.setText(m[2]);
p.setHeading(headingEnum);
}
}
/**
* Convert **bold** markers into real bold formatting.
*/
function convertDoubleAsteriskBold_(body) {
const paras = body.getParagraphs();
for (const p of paras) {
const t = p.editAsText();
if (!t) continue;
const s = t.getText();
if (!s) continue;
const matches = [];
const re = /\*\*([^*]+)\*\*/g;
let m;
while ((m = re.exec(s)) !== null) {
const fullStart = m.index;
const fullEnd = m.index + m[0].length - 1;
const innerStart = fullStart + 2;
const innerEnd = fullEnd - 2;
matches.push({ fullStart, fullEnd, innerStart, innerEnd });
}
for (let i = matches.length - 1; i >= 0; i--) {
const { fullStart, fullEnd, innerStart, innerEnd } = matches[i];
t.setBold(innerStart, innerEnd, true);
t.deleteText(innerEnd + 1, innerEnd + 2); // trailing **
t.deleteText(fullStart, fullStart + 1); // leading **
}
}
}
/**
* Removes an empty paragraph right after a heading (H1–H6).
*/
function removeEmptyLineAfterHeadings_(body) {
const paras = body.getParagraphs();
for (let i = paras.length - 2; i >= 0; i--) {
const p = paras[i];
const next = paras[i + 1];
if (p.getHeading() !== DocumentApp.ParagraphHeading.NORMAL &&
next.getText().trim() === '') {
next.removeFromParent();
}
}
}
/**
* Convert Markdown lists into Google Docs list items.
*
* Supports:
* - Ordered: "1. item", "23. item"
* - Unordered: "* item", "- item", "+ item"
* Nesting level = floor(leadingSpaces/2).
*
* Strategy:
* - Walk paragraphs top-to-bottom.
* - When a line matches a list marker, insert a ListItem at the same index,
* set glyph type & nesting, then remove the original paragraph.
* - Adjacent list items naturally join the same list in Docs when contiguous.
*/
function convertMarkdownLists_(body) {
// Patterns (leading spaces captured for nesting)
const olRe = /^(\s*)(\d+)\.\s+(.*)$/; // " 2. Item"
const ulRe = /^(\s*)([*+-])\s+(.*)$/; // " - Item" / " * Item" / " + Item"
let i = 0;
// We must re-fetch paragraphs length dynamically as we insert/remove.
while (i < body.getNumChildren()) {
const el = body.getChild(i);
if (el.getType() !== DocumentApp.ElementType.PARAGRAPH) { i++; continue; }
const p = el.asParagraph();
if (p.getHeading() !== DocumentApp.ParagraphHeading.NORMAL) { i++; continue; } // don’t touch headings
const raw = p.getText();
if (!raw) { i++; continue; }
let m, isOL = false, isUL = false, indentSpaces = 0, itemText = '';
if ((m = raw.match(olRe))) {
isOL = true;
indentSpaces = m[1].length;
itemText = m[3];
} else if ((m = raw.match(ulRe))) {
isUL = true;
indentSpaces = m[1].length;
itemText = m[3];
}
if (!isOL && !isUL) { i++; continue; }
const nesting = Math.max(0, Math.floor(indentSpaces / 2));
// Insert a list item at the same spot
const li = body.insertListItem(i, itemText);
// Configure glyph and nesting
if (isOL) {
li.setGlyphType(DocumentApp.GlyphType.NUMBER);
} else {
// Choose a simple bullet style; Docs will render nicely
li.setGlyphType(DocumentApp.GlyphType.BULLET);
}
li.setNestingLevel(nesting);
// Remove the original paragraph (it shifted to i+1 after insertion)
p.removeFromParent();
// Move to next index (the newly inserted LI is at i; the next element is i+1)
i++;
// Continue scanning; contiguous items will join the same list automatically.
}
// Optional cleanup: merge stray empty paragraphs between list blocks
// (Docs usually handles this, but we can remove blank lines that slipped in).
for (let j = body.getNumChildren() - 2; j >= 0; j--) {
const cur = body.getChild(j);
const next = body.getChild(j + 1);
if (cur.getType() === DocumentApp.ElementType.PARAGRAPH &&
next.getType() === DocumentApp.ElementType.PARAGRAPH) {
const p1 = cur.asParagraph();
const p2 = next.asParagraph();
if (p1.asParagraph().getText().trim() === '' &&
(p2.getListId || p2.getHeading() !== DocumentApp.ParagraphHeading.NORMAL)) {
// Safe heuristic: remove blank line preceding a list or heading
cur.removeFromParent();
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment