|
// ==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('&', '&') |
|
.replaceAll('<', '<') |
|
.replaceAll('>', '>') |
|
.replaceAll('"', '"') |
|
.replaceAll("'", '''); |
|
} |
|
|
|
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"> </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(); |
|
})(); |