Code View

// SPDX-License-Identifier: MIT
/*
 * FFS-MD formatter — conservative, line-based, dependency-free.
 *
 * FFS-MD has no canonical Markdown writer (MdRenderer only does MD→HTML /
 * MD→plain-text), so this formatter deliberately normalizes only:
 *   - FFS directive fields (whitespace the renderer discards anyway)
 *   - front-matter "key: value" spacing (top-of-file --- block)
 *   - trailing whitespace
 *   - runs of 3+ blank lines -> 2
 *   - a single trailing newline
 * and NEVER touches fenced-code content, table rows, or Markdown structure.
 *
 * Safety invariant: format(x) renders to the same HTML as x. Directive
 * rules mirror MdRenderer.cpp's own field trimming (spec/ffsmd/MDFORMAT.md).
 */

// ── directive canonicalizers ───────────────────────────────────────────

// [IMAGE:...] — prefix exactly "[IMAGE:" (no space). Inner split on '|',
// each field trimmed (matches MdRenderer splitPipe). Empty fields kept.
function formatImage(line) {
  const m = /^\[IMAGE:([\s\S]*)\]\s*$/.exec(line);
  if (!m) return null;
  // strip leading spaces of inner (renderer does this), then trim each field
  const inner = m[1].replace(/^ +/, '');
  const fields = inner.split('|').map((f) => f.trim());
  return '[IMAGE:' + fields.join('|') + ']';
}

// [VIDEO: src] — prefix exactly "[VIDEO: " (WITH one space). src rtrim'd.
function formatVideo(line) {
  const m = /^\[VIDEO: ([\s\S]*)\]\s*$/.exec(line);
  if (!m) return null;
  const src = m[1].trim();
  return '[VIDEO: ' + src + ']';
}

// [SVG: src | alt] — same |-field shape as IMAGE; trim each field. Prefix
// has a space in the header spec, but the fields are what matter; emit the
// compact canonical "[SVG:a|b]" consistent with IMAGE.
function formatSvg(line) {
  const m = /^\[SVG:\s*([\s\S]*)\]\s*$/.exec(line);
  if (!m) return null;
  const fields = m[1].split('|').map((f) => f.trim());
  return '[SVG:' + fields.join('|') + ']';
}

// [LIST:t0,t1,...] — must start with "[LIST:" and end with ']'. Each
// comma-token trimmed (matches MdRenderer).
function formatList(line) {
  const m = /^\[LIST:([\s\S]*)\]\s*$/.exec(line);
  if (!m) return null;
  const toks = m[1].split(',').map((t) => t.trim());
  return '[LIST:' + toks.join(',') + ']';
}

function formatDirective(line) {
  // order matters only in that each guard is exclusive by prefix
  if (line.startsWith('[IMAGE:')) return formatImage(line);
  if (line.startsWith('[VIDEO: ')) return formatVideo(line);
  if (line.startsWith('[SVG:')) return formatSvg(line);
  if (line.startsWith('[LIST:')) return formatList(line);
  return null;
}

// ── fenced code detection ──────────────────────────────────────────────
function fenceMarker(line) {
  // returns the fence token (``` or ~~~, possibly longer) if this line
  // opens/closes a fence, else null. Matches MdRenderer's `substr(0,3)=="```"`
  // plus the common ~~~ variant.
  const m = /^(\s*)(`{3,}|~{3,})/.exec(line);
  return m ? m[2] : null;
}

// ── front-matter ───────────────────────────────────────────────────────
function isFrontmatterFence(line) {
  return /^---\s*$/.test(line);
}
function formatFrontmatterField(line) {
  // "key:   value"  ->  "key: value"; leave lines that aren't key:value
  // (array continuations, blank) untouched except trailing-ws (handled by
  // the caller). Only a leading bare key + colon is reshaped.
  const m = /^([A-Za-z_][A-Za-z0-9_]*):[ \t]*(.*)$/.exec(line);
  if (!m) return line;
  const value = m[2].replace(/\s+$/, '');
  return value.length ? m[1] + ': ' + value : m[1] + ':';
}

// ── main ───────────────────────────────────────────────────────────────
function format(source) {
  // Normalize newlines to \n for processing; remember if CRLF dominated so
  // we can restore it. (Keep it simple: operate in \n, emit \n.)
  const hadTrailingNewline = /\n$/.test(source);
  const lines = source.replace(/\r\n?/g, '\n').split('\n');
  // split() on trailing '\n' yields a final '' element; drop it and re-add
  // a single newline at the end unconditionally.
  if (lines.length && lines[lines.length - 1] === '' && hadTrailingNewline) {
    lines.pop();
  }

  const out = [];
  let inCode = false;
  let codeFence = null;

  // front-matter: only if the very first line is a '---' fence.
  let inFrontmatter = false;
  let frontmatterDone = false;
  if (lines.length > 0 && isFrontmatterFence(lines[0])) {
    inFrontmatter = true;
  }

  for (let idx = 0; idx < lines.length; idx++) {
    let line = lines[idx];

    // ── fenced code: copy verbatim between fences ──
    if (inCode) {
      out.push(line); // literal, including trailing ws / blank lines
      const mk = fenceMarker(line);
      if (mk && mk[0] === codeFence[0] && mk.length >= codeFence.length) {
        inCode = false;
        codeFence = null;
      }
      continue;
    }
    {
      const mk = fenceMarker(line);
      if (mk) {
        // opening fence: normalize only trailing whitespace of the fence line
        out.push(line.replace(/[ \t]+$/, ''));
        inCode = true;
        codeFence = mk;
        continue;
      }
    }

    // ── front-matter block ──
    if (inFrontmatter && !frontmatterDone) {
      if (idx === 0) {
        out.push('---');
        continue;
      }
      if (isFrontmatterFence(line)) {
        out.push('---');
        frontmatterDone = true;
        inFrontmatter = false;
        continue;
      }
      // a field line inside front-matter
      out.push(formatFrontmatterField(line).replace(/[ \t]+$/, ''));
      continue;
    }

    // ── FFS directive lines ──
    const trimmedForDirective = line.replace(/[ \t]+$/, '');
    const directive = formatDirective(trimmedForDirective);
    if (directive !== null) {
      out.push(directive);
      continue;
    }

    // ── ordinary line: strip trailing whitespace ──
    out.push(line.replace(/[ \t]+$/, ''));
  }

  // ── collapse 3+ blank runs to 2, but NEVER inside fenced code ──
  const collapsed = [];
  let blankRun = 0;
  let collapseInCode = false;
  let collapseFence = null;
  for (const line of collapsed_input(out)) {
    const mk = fenceMarker(line);
    if (collapseInCode) {
      collapsed.push(line); // literal
      if (mk && mk[0] === collapseFence[0] && mk.length >= collapseFence.length) {
        collapseInCode = false;
        collapseFence = null;
      }
      blankRun = 0;
      continue;
    }
    if (mk) {
      collapsed.push(line);
      collapseInCode = true;
      collapseFence = mk;
      blankRun = 0;
      continue;
    }
    if (line === '') {
      blankRun++;
      if (blankRun <= 2) collapsed.push(line);
    } else {
      blankRun = 0;
      collapsed.push(line);
    }
  }
  // drop trailing blank lines entirely (file ends with content + one \n)
  while (collapsed.length && collapsed[collapsed.length - 1] === '') {
    collapsed.pop();
  }

  return collapsed.join('\n') + '\n';
}

// identity helper kept for clarity of the fenced-aware pass above
function collapsed_input(arr) {
  return arr;
}

module.exports = { format };