// 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 };