Last active
August 27, 2025 11:42
-
-
Save htlin222/ff736b45c047075cadbeb326448fec26 to your computer and use it in GitHub Desktop.
Google Docs Apps Script | Markdown-Style Heading & Bold Converter
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * 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