A Templater script for Obsidian that batch-renders frontmatter data as inline markdown tables within note bodies.

Setup Instructions

  1. Have Templater enabled and target folder selected
  2. Add one or more of these templates
  3. Place the script below in a file
  4. Run Templater script in a file or ALL

Inline Marker

Add this to any note where you want the snapshot to appear:

<!-- YAML-SNAPSHOT:START -->
<!-- YAML-SNAPSHOT:END -->

Templater Script

<%*
/** ======================
 *  Batch Frontmatter -> Body Snapshot (Markdown Table, preserves [[wikilinks]])
 *  ======================
 *  Configure filters here:
 */
const TARGET_FOLDER = null;  // e.g. "Notes/Projects" or null for whole vault
const REQUIRED_TAG  = null;  // e.g. "publish" (without #) or null to ignore
const SKIP_FILES_WITHOUT_FM = true;  // skip notes that have no frontmatter
 
const START = "<!-- YAML-SNAPSHOT:START -->";
const END   = "<!-- YAML-SNAPSHOT:END -->";
 
/** ---------- Helpers ---------- */
function fmtScalar(v) {
  if (v === null || v === undefined) return 'null';
  if (typeof v === 'boolean' || typeof v === 'number') return String(v);
  const s = String(v);
  if (/^\[\[.*\]\]$/.test(s)) return s;
  return s;
}
function flattenOnce(x) {
  if (Array.isArray(x) && x.length === 1 && Array.isArray(x[0])) return x[0];
  return x;
}
 
function extractFrontmatterBlock(text) {
  if (!text.startsWith('---')) return null;
  const lines = text.split('\n');
  let endIdx = -1;
  for (let i = 1; i < lines.length; i++) {
    if (/^---\s*$/.test(lines[i])) { endIdx = i; break; }
  }
  if (endIdx === -1) return null;
  return lines.slice(1, endIdx).join('\n');
}
 
function findAfterClosingFrontmatterIndex(text) {
  if (!text.startsWith('---')) return -1;
  const lines = text.split('\n');
  let endLine = -1;
  for (let i = 1; i < lines.length; i++) {
    if (/^---\s*$/.test(lines[i])) { endLine = i; break; }
  }
  if (endLine === -1) return -1;
  let pos = 0;
  for (let i = 0; i <= endLine; i++) pos += lines[i].length + 1;
  return pos;
}
 
function parseRawTopListsByKey(fmText) {
  const map = {};
  if (!fmText) return map;
  const lines = fmText.split('\n');
  let currentKey = null;
  for (let i = 0; i < lines.length; i++) {
    const line = lines[i];
    const keyMatch = line.match(/^([^\s][^:]*):\s*(.*)$/);
    if (keyMatch) {
      currentKey = keyMatch[1].trim();
      if (!map[currentKey]) map[currentKey] = [];
      continue;
    }
    if (currentKey) {
      const bulletMatch = line.match(/^\s*-\s+(.*)$/);
      if (bulletMatch) {
        const rawVal = bulletMatch[1].trim();
        map[currentKey].push(rawVal);
        continue;
      }
      if (/^[^\s]/.test(line)) currentKey = null;
    }
  }
  return map;
}
 
function toTableRows(obj, rawTopLists = {}, parentKey = null) {
  const rows = [];
  if (Array.isArray(obj)) {
    const arr = flattenOnce(obj);
    if (!arr.length) return ['_none_'];
    const rawList = parentKey ? (rawTopLists[parentKey] || []) : [];
    return arr.map((item, i) => {
      if (rawList[i]) return rawList[i];
      if (Array.isArray(item)) return flattenOnce(item).map(sub => fmtScalar(sub)).join(', ');
      if (item && typeof item === 'object') return JSON.stringify(item);
      return fmtScalar(item);
    });
  }
  for (const [k, v] of Object.entries(obj || {})) {
    if (Array.isArray(v)) {
      const vals = toTableRows(v, rawTopLists, k);
      rows.push([`**${k}**`, vals.join(', ')]);
    } else if (v && typeof v === 'object') {
      rows.push([`**${k}**`, JSON.stringify(v)]);
    } else {
      rows.push([`**${k}**`, fmtScalar(v)]);
    }
  }
  return rows;
}
 
function hasRequiredTag(cache, tag) {
  if (!tag) return true;
  const allTags = (cache?.tags || []).map(t => t.tag.replace(/^#/, ''));
  return allTags.includes(tag);
}
function inTargetFolder(tfile, folder) {
  if (!folder) return true;
  return tfile.path.startsWith(folder.endsWith('/') ? folder : folder + '/');
}
 
function upsertAfterFrontmatter(content, block) {
  const blockRegex = new RegExp(`${START}[\\s\\S]*?${END}[ \\t]*(\\r?\\n)*`, 'g');
  let stripped = content.replace(blockRegex, '');
  const insertPos = findAfterClosingFrontmatterIndex(stripped);
  if (insertPos === -1) {
    stripped = stripped.replace(/^\s+/, '');
    let next = `${block}\n\n${stripped}`;
    const collapseAfterEnd = new RegExp(`${END}[ \\t]*\\r?\\n{2,}`, 'g');
    next = next.replace(collapseAfterEnd, `${END}\n`);
    return next;
  }
  const beforeRaw = stripped.slice(0, insertPos);
  const afterRaw  = stripped.slice(insertPos);
  const before = beforeRaw.replace(/[ \t]*(\r?\n)*$/, '\n');
  const after = afterRaw.replace(/^[ \t]*(\r?\n){2,}/, '\n');
  let next = `${before}${block}\n${after}`;
  const collapseAfterEnd = new RegExp(`${END}[ \\t]*\\r?\\n{2,}`, 'g');
  next = next.replace(collapseAfterEnd, `${END}\n`);
  return next;
}
 
/** ---------- Main ---------- */
const files = app.vault.getMarkdownFiles();
let processed = 0, skipped = 0, changed = 0;
 
for (const f of files) {
  if (!inTargetFolder(f, TARGET_FOLDER)) { skipped++; continue; }
  const cache = app.metadataCache.getFileCache(f) || {};
  if (!hasRequiredTag(cache, REQUIRED_TAG)) { skipped++; continue; }
  const fm = cache.frontmatter;
  if (!fm && SKIP_FILES_WITHOUT_FM) { skipped++; continue; }
  const cur = await app.vault.read(f);
  const fmRaw = extractFrontmatterBlock(cur);
  const rawTopLists = parseRawTopListsByKey(fmRaw);
  const rows = toTableRows(fm || {}, rawTopLists);
  let table = '';
  if (rows.length) {
    table += `| Key | Value |\n| --- | ----- |\n`;
    for (const row of rows) {
      if (Array.isArray(row)) {
        const [key, val] = row;
        table += `| ${key} | ${val} |\n`;
      } else {
        table += `|  | ${row} |\n`;
      }
    }
  }
  const block = `${START}\n${table}\n${END}`;
  const next = upsertAfterFrontmatter(cur, block);
  if (next !== cur) {
    await app.vault.modify(f, next);
    changed++;
  }
  processed++;
}
 
tR += ``;
%>