Skip to content

Instantly share code, notes, and snippets.

@intellectronica
Last active December 12, 2025 07:58
Show Gist options
  • Select an option

  • Save intellectronica/e13c0928f5d38ecd7ad3c2dcbe066fe5 to your computer and use it in GitHub Desktop.

Select an option

Save intellectronica/e13c0928f5d38ecd7ad3c2dcbe066fe5 to your computer and use it in GitHub Desktop.
MonkeyScript: GitHub Frontmatter Fix

GitHub Frontmatter Fix

Make GitHub markdown frontmatter readable again - collapsible, syntax-highlighted YAML!

Install this user script (using a monkey extension like TamperMonkey or similar) to reformat frontmatter in markdown files on GitHub (in both repos and gists) as collapsible, syntax-highlighted YAML.


Happy Frontmattering!

🫶 Eleanor (@intellectronica)


// ==UserScript==
// @name GitHub Frontmatter as YAML
// @namespace https://intellectronica.net/
// @version 1.0.0
// @description Reformat GitHub/Gist Markdown frontmatter tables into a collapsible YAML code block.
// @author Eleanor Berger <[email protected]>
// @match https://github.com/*
// @match https://gist.github.com/*
// @run-at document-idle
// @require https://cdn.jsdelivr.net/npm/[email protected]/dist/js-yaml.min.js
// @grant GM_xmlhttpRequest
// @connect github.com
// @connect gist.github.com
// @connect gist.githubusercontent.com
// @connect raw.githubusercontent.com
// ==/UserScript==
(function () {
'use strict';
const PROCESSED_ATTR = 'data-gh-frontmatter-yaml-processed';
const BLOCK_CLASS = 'gh-frontmatter-yaml-block';
const ALLOW_TABLE_FALLBACK = false;
function logDebug(...args) {
// Flip to true while debugging.
const DEBUG = false;
if (DEBUG) console.debug('[frontmatter-yaml]', ...args);
}
function onceInjectStyles() {
if (document.getElementById('gh-frontmatter-yaml-style')) return;
const style = document.createElement('style');
style.id = 'gh-frontmatter-yaml-style';
style.textContent = `
/* Keep things subtle; let GitHub theme do the heavy lifting. */
.${BLOCK_CLASS} {
margin: 0 0 16px 0;
}
.${BLOCK_CLASS} pre {
margin-top: 0;
}
/* Wrap long lines for readability (display only). */
.${BLOCK_CLASS} code.gh-fm-yaml {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
/* Line wrappers so we can collapse to the first line while keeping a "code block" visible. */
.${BLOCK_CLASS} .gh-fm-line {
display: block;
}
.${BLOCK_CLASS} .gh-fm-toggle-line {
width: 100%;
cursor: pointer;
user-select: none;
color: var(--fgColor-muted, #57606a);
font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;
}
.${BLOCK_CLASS} .gh-fm-toggle-line:focus {
outline: 2px solid var(--focus-outlineColor, #0969da);
outline-offset: 2px;
}
.${BLOCK_CLASS} .gh-fm-fence-text {
font-weight: 600;
}
.${BLOCK_CLASS} .gh-fm-toggle-gap {
display: inline-block;
width: 0.5em;
}
.${BLOCK_CLASS} .gh-fm-inline-chevron {
display: inline-block;
width: 1em;
opacity: 0.7;
}
.${BLOCK_CLASS} .gh-fm-inline-chevron::after { content: '▾'; }
.${BLOCK_CLASS}.is-collapsed .gh-fm-inline-chevron::after { content: '▸'; }
.${BLOCK_CLASS} .gh-fm-fence-line {
color: var(--fgColor-muted, #57606a);
}
.${BLOCK_CLASS}.is-collapsed .gh-fm-line:not(.gh-fm-toggle-line) {
display: none;
}
`;
document.head.appendChild(style);
}
function escapeHtml(s) {
return String(s)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function splitYamlComment(line) {
// Split on the first # that is not inside single or double quotes.
let inSingle = false;
let inDouble = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (ch === "'" && !inDouble) {
// YAML single quotes escape by doubling (''), keep it simple.
const next = line[i + 1];
if (inSingle && next === "'") {
i++;
continue;
}
inSingle = !inSingle;
continue;
}
if (ch === '"' && !inSingle) {
// Basic escape handling for \\".
const prev = line[i - 1];
if (prev !== '\\') inDouble = !inDouble;
continue;
}
if (ch === '#' && !inSingle && !inDouble) {
return { code: line.slice(0, i), comment: line.slice(i) };
}
}
return { code: line, comment: '' };
}
function highlightYamlLineToHtml(line) {
const trimmed = line.trim();
// This function intentionally does not handle YAML fences; those are rendered
// by the surrounding code block renderer.
const { code, comment } = splitYamlComment(line);
let html = '';
// Preserve leading whitespace.
const mIndent = code.match(/^(\s*)/);
const indent = mIndent ? mIndent[1] : '';
let rest = code.slice(indent.length);
html += escapeHtml(indent);
// List item dash.
const mDash = rest.match(/^-(\s+)/);
if (mDash) {
html += `<span class="pl-k">-</span>${escapeHtml(mDash[1])}`;
rest = rest.slice(1 + mDash[1].length);
}
// Key-value form.
const mKey = rest.match(/^([A-Za-z0-9_.-]+)(\s*):(\s*)(.*)$/);
if (mKey) {
const [, key, colonWs, afterWs, value] = mKey;
html += `<span class="pl-ent">${escapeHtml(key)}</span>`;
// Preserve whitespace before the colon, then render the colon itself.
html += `${escapeHtml(colonWs)}<span class="pl-k">:</span>${escapeHtml(afterWs)}`;
html += highlightYamlScalarToHtml(value);
} else {
// Not a key-value line; still highlight scalars a bit.
html += highlightYamlScalarToHtml(rest);
}
if (comment) {
html += `<span class="pl-c">${escapeHtml(comment)}</span>`;
}
return html;
}
function highlightYamlScalarToHtml(value) {
const v = value ?? '';
// Quoted strings.
if (/^\s*".*"\s*$/.test(v) || /^\s*'.*'\s*$/.test(v)) {
return `<span class="pl-s">${escapeHtml(v)}</span>`;
}
// Simple scalars.
if (/^\s*(true|false|null|~)\s*$/i.test(v)) {
return `<span class="pl-c1">${escapeHtml(v)}</span>`;
}
if (/^\s*[-+]?(?:0|[1-9]\d*)(?:\.\d+)?\s*$/.test(v)) {
return `<span class="pl-c1">${escapeHtml(v)}</span>`;
}
return escapeHtml(v);
}
function highlightYamlBlockToHtml(yamlBlock) {
const lines = String(yamlBlock).replace(/\r\n/g, '\n').split('\n');
return lines.map(highlightYamlLineToHtml).join('\n');
}
function normalizeYamlForDisplay(frontmatterInner) {
// Parse and re-dump for stable formatting, proper quoting, and wrapping.
// Requires js-yaml via @require.
const jsyaml = window.jsyaml;
if (!jsyaml) return { text: String(frontmatterInner ?? ''), normalized: false };
try {
const obj = jsyaml.load(String(frontmatterInner ?? ''));
const dumped = jsyaml.dump(obj, {
lineWidth: 88,
noRefs: true,
sortKeys: false,
quotingType: '"',
forceQuotes: false,
});
return { text: String(dumped ?? '').trimEnd(), normalized: true };
} catch (e) {
logDebug('YAML parse/dump failed; falling back to raw inner', e);
return { text: String(frontmatterInner ?? ''), normalized: false };
}
}
function gmGetText(url) {
return new Promise((resolve, reject) => {
try {
GM_xmlhttpRequest({
method: 'GET',
url,
headers: {
'Accept': 'text/plain',
},
onload: (resp) => {
if (resp.status >= 200 && resp.status < 300) {
resolve(resp.responseText);
} else {
reject(new Error(`HTTP ${resp.status} for ${url}`));
}
},
onerror: () => reject(new Error(`Network error for ${url}`)),
});
} catch (e) {
reject(e);
}
});
}
function extractFrontmatterBlock(markdownText) {
if (!markdownText) return null;
const text = markdownText.replace(/^\uFEFF/, '');
const lines = text.split(/\r?\n/);
// Frontmatter must be the first meaningful block. Skip empty lines at the very top.
let i = 0;
while (i < lines.length && lines[i].trim() === '') i++;
if (i >= lines.length) return null;
if (lines[i].trim() !== '---') return null;
const start = i;
let end = -1;
for (let j = i + 1; j < lines.length; j++) {
const t = lines[j].trim();
if (t === '---' || t === '...') {
end = j;
break;
}
}
if (end === -1) return null;
const inner = lines.slice(start + 1, end).join('\n');
const normalizedBlock = ['---', inner, '---'].join('\n').replace(/\n{3,}/g, '\n\n');
return {
inner,
block: normalizedBlock,
};
}
function escapeYamlString(s) {
// Minimal heuristic: keep safe scalars unquoted, quote everything else.
const trimmed = (s ?? '').trim();
if (trimmed === '') return '""';
if (/^(true|false|null|~|[-+]?(?:0|[1-9]\d*)(?:\.\d+)?)$/i.test(trimmed)) return JSON.stringify(trimmed);
if (/^[A-Za-z0-9_./-]+$/.test(trimmed)) return trimmed;
return JSON.stringify(trimmed);
}
function tableToYamlFallback(tableEl) {
const ths = Array.from(tableEl.querySelectorAll(':scope > thead > tr > th'))
.map((th) => th.textContent.trim())
.filter(Boolean);
const row = tableEl.querySelector(':scope > tbody > tr');
const tds = row ? Array.from(row.querySelectorAll(':scope > td')) : [];
if (!ths.length || !tds.length) return null;
const lines = [];
for (let i = 0; i < Math.min(ths.length, tds.length); i++) {
const key = ths[i];
const td = tds[i];
const nested = td.querySelector('table');
if (nested) {
const items = Array.from(nested.querySelectorAll('td'))
.map((x) => x.textContent.trim())
.filter(Boolean);
if (items.length) {
lines.push(`${key}:`);
for (const it of items) lines.push(` - ${escapeYamlString(it)}`);
} else {
lines.push(`${key}: []`);
}
} else {
const value = td.textContent.trim();
lines.push(`${key}: ${escapeYamlString(value)}`);
}
}
return lines.join('\n');
}
function renderYamlCodeHtmlWithFencesAndToggle(yamlInner) {
const inner = String(yamlInner ?? '').replace(/\r\n/g, '\n');
const innerLines = inner.length ? inner.split('\n') : [];
const linesHtml = [];
// First fence line is the toggle.
linesHtml.push(
`<span class="gh-fm-line gh-fm-toggle-line" role="button" tabindex="0" aria-expanded="true">`
+ `<span class="gh-fm-fence-text">---</span>`
+ `<span class="gh-fm-toggle-gap" aria-hidden="true">&nbsp;</span>`
+ `<span class="gh-fm-inline-chevron" aria-hidden="true"></span>`
+ `</span>`
);
for (const line of innerLines) {
linesHtml.push(`<span class="gh-fm-line">${highlightYamlLineToHtml(line)}</span>`);
}
// Closing fence.
linesHtml.push(`<span class="gh-fm-line gh-fm-fence-line"><span class="gh-fm-fence-text">---</span></span>`);
// IMPORTANT: Do not join with "\n" here.
// These lines are rendered as block-level spans inside a <pre>, and newline text nodes
// between them become their own rendered blank lines.
return linesHtml.join('');
}
function installToggleBehavior(containerEl) {
const toggle = containerEl.querySelector('.gh-fm-toggle-line');
if (!toggle) return;
const setExpanded = (expanded) => {
containerEl.classList.toggle('is-collapsed', !expanded);
toggle.setAttribute('aria-expanded', expanded ? 'true' : 'false');
};
const onActivate = () => {
const expanded = !containerEl.classList.contains('is-collapsed');
setExpanded(!expanded);
};
toggle.addEventListener('click', (e) => {
e.preventDefault();
onActivate();
});
toggle.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onActivate();
}
});
}
function makeCollapsibleYamlBlock(yamlInner) {
onceInjectStyles();
const container = document.createElement('div');
container.className = BLOCK_CLASS;
const pre = document.createElement('pre');
const code = document.createElement('code');
code.className = 'language-yaml gh-fm-yaml';
code.innerHTML = renderYamlCodeHtmlWithFencesAndToggle(yamlInner);
pre.appendChild(code);
container.appendChild(pre);
installToggleBehavior(container);
return container;
}
function findFrontmatterTableWrapper(markdownBody) {
if (!markdownBody) return null;
// GitHub wraps these tables in a custom element.
const firstElement = markdownBody.firstElementChild;
if (!firstElement) return null;
const isWrapper = /^(MARKDOWN-ACCESSIBLITY-TABLE|MARKDOWN-ACCESSIBILITY-TABLE)$/.test(firstElement.tagName);
if (!isWrapper) return null;
const table = firstElement.querySelector('table');
if (!table) return null;
// Heuristic: frontmatter table tends to be a single data row.
const hasThead = !!table.querySelector('thead');
const bodyRows = table.querySelectorAll(':scope > tbody > tr');
if (!hasThead || bodyRows.length !== 1) return null;
return { wrapper: firstElement, table };
}
function findRawUrlForRepoPage() {
const fileName = (location.pathname.split('/').pop() || '').toLowerCase();
const candidates = Array.from(document.querySelectorAll('a[href*="/raw/"]'))
.map((a) => ({ a, href: a.href }))
.filter((x) => x.href && x.href.includes('/raw/'));
// Prefer raw link that ends with the current filename.
for (const c of candidates) {
try {
const u = new URL(c.href);
if (fileName && u.pathname.toLowerCase().endsWith('/' + fileName)) return c.href;
} catch {
// ignore
}
}
// Fallback: known IDs (if present on some GitHub layouts).
const rawById = document.querySelector('a#raw-url');
if (rawById?.href) return rawById.href;
return candidates[0]?.href || null;
}
function findRawUrlForGistFile(markdownBody) {
const fileContainer = markdownBody.closest('.file');
if (!fileContainer) return null;
const fileName = fileContainer.querySelector('a[href^="#file-"] strong')?.textContent?.trim();
const rawLink = fileContainer.querySelector('a[href*="/raw/"]');
if (rawLink?.href) {
// If multiple raw links exist in file container (rare), prefer the one ending with filename.
if (fileName) {
const all = Array.from(fileContainer.querySelectorAll('a[href*="/raw/"]'));
for (const a of all) {
try {
const u = new URL(a.href);
if (u.pathname.endsWith('/' + fileName)) return a.href;
} catch {
// ignore
}
}
}
return rawLink.href;
}
return null;
}
async function processMarkdownBody(markdownBody) {
const found = findFrontmatterTableWrapper(markdownBody);
if (!found) return;
const { wrapper, table } = found;
if (wrapper.getAttribute(PROCESSED_ATTR) === 'true') return;
wrapper.setAttribute(PROCESSED_ATTR, 'true');
const isGist = location.hostname === 'gist.github.com';
const rawUrl = isGist ? findRawUrlForGistFile(markdownBody) : findRawUrlForRepoPage();
let yamlInner = null;
if (rawUrl) {
try {
const rawText = await gmGetText(rawUrl);
const fm = extractFrontmatterBlock(rawText);
if (fm?.inner != null) {
// Present YAML content; fences are rendered inside the code block.
yamlInner = normalizeYamlForDisplay(fm.inner).text;
}
} catch (e) {
logDebug('raw fetch failed', rawUrl, e);
}
}
if (!yamlInner && ALLOW_TABLE_FALLBACK) {
// Fallback is intentionally disabled by default to avoid transforming ordinary tables
// that happen to appear at the top of a Markdown document.
yamlInner = tableToYamlFallback(table);
}
if (!yamlInner) {
// Nothing to do; unmark so we can retry later.
wrapper.removeAttribute(PROCESSED_ATTR);
return;
}
const blockEl = makeCollapsibleYamlBlock(yamlInner);
// Replace wrapper (which contains the table) with our collapsible YAML display.
wrapper.replaceWith(blockEl);
}
async function scanAndProcess() {
const markdownBodies = Array.from(document.querySelectorAll('article.markdown-body'));
if (!markdownBodies.length) return;
// Process sequentially to keep requests low-noise.
for (const mb of markdownBodies) {
try {
await processMarkdownBody(mb);
} catch (e) {
logDebug('processMarkdownBody failed', e);
}
}
}
// Debounced runner so we don’t fight GitHub’s DOM churn.
let scanTimer = null;
function scheduleScan() {
if (scanTimer) clearTimeout(scanTimer);
scanTimer = setTimeout(() => {
scanTimer = null;
scanAndProcess();
}, 200);
}
function installNavigationHooks() {
// GitHub uses Turbo; some areas still emit PJAX-ish events.
window.addEventListener('turbo:load', scheduleScan, true);
window.addEventListener('pjax:end', scheduleScan, true);
// DOM observer for in-page file switches / gist file tabs.
const mo = new MutationObserver(() => scheduleScan());
mo.observe(document.documentElement, { childList: true, subtree: true });
}
installNavigationHooks();
scheduleScan();
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment