This website is meant to be read and understood quickly by humans, but is only fully parsable, on a technical level, with the aid of an AI system. Read why →
Loop MMT
One Markdown Source, Many Honest Dialects?transform← all gifts

dialect

Every tool that posts Markdown to somewhere re-hand-rolls the same emitter — walk the AST, decide how each node looks in the target, glue the strings — and the usual over-correction is worse: a template DSL with conditionals and expressions, a programming language in disguise. dialect takes the middle: it renders a parsed Markdown AST into a target text dialect (a Reddit comment, a Reddit self-post, plain text, ...) by folding it through a DECLARATIVE RULESET — a flat JSON table keyed by node type, each value a small record of bounded knobs (a wrap pair, a linePrefix, a form choice, a link template) with NO conditionals, NO expressions, NO loops you can author and a fixed tiny placeholder set ({content}{level}{n}{label}{href}). Adding a dialect is adding a table, not writing code, and new syntaxes (a Slack *bold*, a Discord ||spoiler||) are just new wrap pairs. It ships beside the markdown gift and folds over ITS AST — reusing that gift's parse() (no second parser) and its isSafeUrl guard. render() is a pure fold: same AST + same ruleset produce byte-identical text, and an external ruleset is validated at load against four decidable structural predicates (wrap-is-a-pair, blockJoin-string, template-has-{label}+{href}, linePrefix-string), loud-stopping on a malformed one. The line it will not cross is `table`: that needs a code writer, and the edge says so out loud.

The honest edge
The declarative ruleset covers the ~95% of Markdown whose emission is a flat per-node substitution; it STOPS at `table` (column alignment is logic, not a flat substitution, so a table target needs a code writer) — a table node renders its cells bare and is REPORTED as a hole, never silently mangled. Link safety is universal, not a per-dialect choice: an unsafe href (javascript:, data:, ...) is ALWAYS dropped to its plain-text label, in every dialect. It holds NO clock/network/entropy, persists nothing, and never throws on bad input — an unparseable construct degrades to literal text, an unruled node renders bare and is reported. An external ruleset is validated at load and loud-stops on a structural fault.
Run it
node dialect.js --ruleset reddit IN.md # Markdown -> dialect text | node dialect.js --list | node dialect.js --help test_dialect.js (30/30, 3 mutation bites) + --selftest (23) — per-dialect deltas (reddit/reddit-post/plain), unsafe-href drop, hole reporting, ruleset-load validator (four decidable predicates), folds-twice-identical determinism Zero dependencies, Node or browser, deterministic (folds beside the markdown gift)
The code — every file that ships
dialect.js484 lineson GitHub →
#!/usr/bin/env node
/* SPDX-License-Identifier: MIT */
/* dialect.js — one Markdown source, many honest dialects, from DATA not code.
 *
 * A tiny, dependency-free engine that renders a parsed Markdown AST into a target
 * text dialect (Reddit comment, plain text, …) by folding it through a DECLARATIVE
 * RULESET — a flat table of per-node emission rules — instead of a hand-written
 * emitter per target.
 *
 *     source ──▶ parse() ──▶ AST ──▶ render(ast, RULESET) ──▶ dialect text
 *
 * WHY DATA, NOT CODE (the design decision, and the line it will not cross).
 * The obvious way to support a second target is a second emitter function. That's
 * the N-writers path, and it grows code without bound. The obvious OVER-correction
 * is a template DSL with conditionals and expressions — a programming language in
 * disguise, which is where "flexible" turns baroque. This engine takes the middle:
 * a ruleset is a flat JSON object keyed by AST node type, each value a small record
 * of bounded knobs (a wrap pair, a line-prefix, a marker, a form choice). It has
 * NO conditionals, NO expressions, NO loops you can author. Substitution is a fixed,
 * tiny set of placeholders: {content} {level} {n} {label} {href}. That is expressive
 * enough for the real dialects and it STOPS DEAD before becoming a language. A target
 * that genuinely needs logic gets a code writer; the ruleset covers the declarative 95%.
 *
 * THE REUSE (this ships beside the `markdown` gift and folds over ITS AST).
 * It require()s the markdown gift's parse() — no second parser — and reuses that
 * gift's isSafeUrl href guard, because link safety is never a per-dialect choice:
 * an unsafe href (javascript:, data:, …) is ALWAYS dropped to its plain-text label,
 * in every dialect, by construction.
 *
 * HONESTY / DETERMINISM (inherited and kept):
 *   * parse() never throws — an unparseable construct degrades to literal text.
 *   * render() is a pure fold — same AST + same ruleset ⇒ byte-identical output.
 *     --selftest proves folds-twice-identical.
 *   * A ruleset with a hole (a node type it doesn't name) does not crash: the node
 *     renders its content bare, and the hole is REPORTED, never silently dropped.
 *
 * THE RULESET SHAPE (the whole schema — a dialect that hides its scope lies):
 *   {
 *     "name": "reddit",
 *     "blockJoin": "\n\n",            // separator between top-level blocks
 *     "blocks": {
 *       "heading":    { "form": "bold" | "atx", ... },   // bold-line vs # line
 *       "paragraph":  {},                                 // content as-is
 *       "blockquote": { "linePrefix": "> ", "innerJoin": "\n>\n" },
 *       "codeBlock":  { "form": "indent" | "fence", "indent": "    ", "fence": "```" },
 *       "list":       { "bullet": "- ", "ordered": "{n}. " }
 *     },
 *     "inline": {
 *       "strong":   { "wrap": ["**","**"] },
 *       "em":       { "wrap": ["*","*"] },
 *       "codeSpan": { "wrap": ["`","`"] },
 *       "break":    { "text": "  \n" },
 *       "link":     { "form": "inline" | "label" | "labelHref",  // [t](u) | t | t (u)
 *                     "template": "[{label}]({href})" }          // form:inline uses this
 *     }
 *   }
 * Any block/inline key omitted falls back to a bare, honest default (content only).
 *
 * API (require):  render(ast, ruleset) -> { text, holes:[nodeType…] }
 *                 renderSource(src, ruleset) -> same, parsing src first
 * CLI:            dialect.js --ruleset reddit IN.md [-o OUT.md]
 *                 dialect.js --ruleset ./my-rules.json IN.md
 *                 dialect.js --list           (built-in rulesets)
 *                 dialect.js --selftest
 */
'use strict';

var path = require('path');
// Resolve the markdown gift. In the SHIPPED (served) home it's a sibling gift dir
// (../markdown/markdown.js). In the repo's design tree the served gift lives under
// projects/loopmmt-website/... — walk up looking for that. Try candidates in order;
// the first that loads wins. (A gift is zero-dependency at ship time — this dual
// path is a repo-checkout convenience, not a runtime dependency.)
var md = (function () {
  var candidates = [
    path.resolve(__dirname, '..', 'markdown', 'markdown.js') // shipped: sibling gift
  ];
  // walk parents looking for the served gifts dir
  var d = __dirname;
  for (var up = 0; up < 8; up++) {
    candidates.push(path.resolve(d, 'projects', 'loopmmt-website', 'site-root', 'gifts', 'markdown', 'markdown.js'));
    d = path.resolve(d, '..');
  }
  for (var i = 0; i < candidates.length; i++) {
    try { return require(candidates[i]); } catch (e) { /* try next */ }
  }
  throw new Error('dialect: cannot locate the markdown gift (markdown.js) in any known home');
})();

// ---- built-in rulesets ------------------------------------------------------
// Data, not code. Add a dialect by adding a table here (or passing a JSON file).
var RULESETS = {
  // Reddit COMMENT dialect: headings->bold line, blocks blank-line-separated,
  // code fences -> 4-space indent (``` unreliable in old-Reddit comments).
  reddit: {
    name: 'reddit',
    desc: 'Reddit comment — bold-line headers, blank-line blocks, indented code.',
    blockJoin: '\n\n',
    blocks: {
      heading:    { form: 'bold' },
      paragraph:  {},
      blockquote: { linePrefix: '> ', innerJoin: '\n>\n' },
      codeBlock:  { form: 'indent', indent: '    ' },
      list:       { bullet: '- ', ordered: '{n}. ' }
    },
    inline: {
      strong:   { wrap: ['**', '**'] },
      em:       { wrap: ['*', '*'] },
      codeSpan: { wrap: ['`', '`'] },
      break:    { text: '  \n' },
      link:     { form: 'inline', template: '[{label}]({href})' }
    }
  },

  // Reddit SELF-POST variant: keep # headings and ``` fences (posts render them).
  'reddit-post': {
    name: 'reddit-post',
    desc: 'Reddit self-post — keeps # headers and ``` fences (posts render them).',
    blockJoin: '\n\n',
    blocks: {
      heading:    { form: 'atx' },
      paragraph:  {},
      blockquote: { linePrefix: '> ', innerJoin: '\n>\n' },
      codeBlock:  { form: 'fence', fence: '```' },
      list:       { bullet: '- ', ordered: '{n}. ' }
    },
    inline: {
      strong:   { wrap: ['**', '**'] },
      em:       { wrap: ['*', '*'] },
      codeSpan: { wrap: ['`', '`'] },
      break:    { text: '  \n' },
      link:     { form: 'inline', template: '[{label}]({href})' }
    }
  },

  // Plain text: strip all markup. Emphasis vanishes; a link becomes "label (href)".
  plain: {
    name: 'plain',
    desc: 'Plain text — every mark stripped; a link becomes "label (href)".',
    blockJoin: '\n\n',
    blocks: {
      heading:    { form: 'bold-none' }, // content only, no marker
      paragraph:  {},
      blockquote: { linePrefix: '', innerJoin: '\n\n' },
      codeBlock:  { form: 'indent', indent: '    ' },
      list:       { bullet: '- ', ordered: '{n}. ' }
    },
    inline: {
      strong:   { wrap: ['', ''] },
      em:       { wrap: ['', ''] },
      codeSpan: { wrap: ['', ''] },
      break:    { text: '\n' },
      link:     { form: 'labelHref' } // "label (href)"
    }
  }
};

// ---- the engine: a pure fold of an AST through a ruleset ---------------------
function render(ast, ruleset) {
  var holes = {};
  if (!ast || ast.type !== 'document') return { text: '', holes: [] };
  var rs = ruleset || RULESETS.reddit;
  var blockJoin = rs.blockJoin != null ? rs.blockJoin : '\n\n';
  var out = ast.children.map(function (node) {
    return renderBlock(node, rs, holes);
  }).filter(function (s) { return s !== null && s !== ''; });
  return { text: out.join(blockJoin) + '\n', holes: Object.keys(holes) };
}

function renderBlock(node, rs, holes) {
  var rule = (rs.blocks && rs.blocks[node.type]) || null;
  switch (node.type) {
    case 'heading': {
      var body = renderInline(node.children, rs, holes);
      var form = rule ? rule.form : 'bold';
      if (form === 'atx') return repeat('#', node.level) + ' ' + body;
      if (form === 'bold-none') return body;
      return '**' + body + '**'; // 'bold' default
    }
    case 'paragraph':
      return renderInline(node.children, rs, holes);
    case 'blockquote': {
      var prefix = rule && rule.linePrefix != null ? rule.linePrefix : '> ';
      var innerJoin = rule && rule.innerJoin != null ? rule.innerJoin : '\n>\n';
      return node.children.map(function (b) {
        var inner = renderBlock(b, rs, holes);
        return inner.split('\n').map(function (ln) { return prefix + ln; }).join('\n');
      }).join(innerJoin);
    }
    case 'codeBlock': {
      var text = node.text.replace(/\n$/, '');
      var cform = rule ? rule.form : 'indent';
      if (cform === 'fence') {
        var fence = rule && rule.fence ? rule.fence : '```';
        return fence + '\n' + text + '\n' + fence;
      }
      var indent = rule && rule.indent ? rule.indent : '    ';
      return text.split('\n').map(function (ln) { return indent + ln; }).join('\n');
    }
    case 'list': {
      var bullet = rule && rule.bullet ? rule.bullet : '- ';
      var orderedTpl = rule && rule.ordered ? rule.ordered : '{n}. ';
      var n = 0;
      return node.items.map(function (it) {
        n++;
        var marker = node.ordered ? orderedTpl.replace('{n}', String(n)) : bullet;
        return marker + renderInline(it.children, rs, holes);
      }).join('\n');
    }
    default:
      holes[node.type] = true;
      return node.children ? renderInline(node.children, rs, holes) : '';
  }
}

function renderInline(nodes, rs, holes) {
  var inline = rs.inline || {};
  return (nodes || []).map(function (n) {
    switch (n.type) {
      case 'text': return n.value;
      case 'strong': return wrapWith(inline.strong, ['**', '**'], renderInline(n.children, rs, holes));
      case 'em': return wrapWith(inline.em, ['*', '*'], renderInline(n.children, rs, holes));
      case 'codeSpan': return wrapWith(inline.codeSpan, ['`', '`'], n.value);
      case 'break': return inline.break && inline.break.text != null ? inline.break.text : '  \n';
      case 'link': {
        var label = renderInline(n.children, rs, holes);
        // link safety is universal, never a per-dialect choice
        if (!md.isSafeUrl(n.href)) return label;
        var href = n.href.trim();
        var lr = inline.link || { form: 'inline', template: '[{label}]({href})' };
        if (lr.form === 'label') return label;
        if (lr.form === 'labelHref') return label + ' (' + href + ')';
        var tpl = lr.template || '[{label}]({href})';
        return tpl.replace('{label}', label).replace('{href}', href);
      }
      default:
        holes[n.type] = true;
        return n.children ? renderInline(n.children, rs, holes) : (n.value || '');
    }
  }).join('');
}

function wrapWith(rule, dflt, content) {
  var w = rule && rule.wrap ? rule.wrap : dflt;
  return (w[0] || '') + content + (w[1] || '');
}
function repeat(s, n) { var o = ''; for (var k = 0; k < n; k++) o += s; return o; }

function renderSource(src, ruleset) {
  return render(md.parse(String(src == null ? '' : src)), ruleset);
}
function resolveRuleset(nameOrObj) {
  if (nameOrObj && typeof nameOrObj === 'object') return nameOrObj;
  return RULESETS[nameOrObj] || null;
}

// ---- ruleset-load validator -------------------------------------------------
// Validate a ruleset at LOAD for exactly FOUR decidable STRUCTURAL predicates,
// and loud-stop (throw) on failure. This checks the SHAPE the engine substitutes
// into, never the SEMANTICS of a dialect — an unknown node type, a missing block
// rule, an unusual marker are all honest-degraded at render time (holes reported,
// content emitted bare), so they are deliberately NOT checked here.
//
// It stays FOUR predicates on purpose (RCR minority guard, Dara): the moment a
// ruleset wants conditionals or expressions, that is the Simplicity-Yield line —
// the answer is a per-target code writer, NOT a fifth predicate or a richer one.
//
//   1. every `wrap` value is a 2-element [open, close] pair
//   2. `blockJoin`, when present, is a string
//   3. any `template` string contains the placeholders it references — {label}
//      and {href} (the two the link renderer substitutes); a template missing
//      either silently drops content, so this is a real structural fault
//   4. any `linePrefix`, when present, is a string
//
// Returns the ruleset unchanged on success; throws Error naming every violation
// on failure. Pure and decidable — no I/O, no render, terminates on any input.
function validateRuleset(ruleset) {
  if (!ruleset || typeof ruleset !== 'object' || Array.isArray(ruleset)) {
    throw new Error('invalid ruleset: expected an object');
  }
  var errs = [];
  var inline = (ruleset.inline && typeof ruleset.inline === 'object') ? ruleset.inline : {};
  var blocks = (ruleset.blocks && typeof ruleset.blocks === 'object') ? ruleset.blocks : {};

  // (1) every `wrap` value is a 2-element pair
  Object.keys(inline).forEach(function (k) {
    var rule = inline[k];
    if (rule && rule.wrap !== undefined) {
      if (!Array.isArray(rule.wrap) || rule.wrap.length !== 2) {
        errs.push('inline.' + k + '.wrap must be a 2-element [open, close] pair');
      }
    }
  });

  // (2) blockJoin is a string (when present)
  if (ruleset.blockJoin !== undefined && typeof ruleset.blockJoin !== 'string') {
    errs.push('blockJoin must be a string');
  }

  // (3) any template string contains the placeholders it references ({label} and {href})
  Object.keys(inline).forEach(function (k) {
    var rule = inline[k];
    if (rule && rule.template !== undefined) {
      if (typeof rule.template !== 'string') {
        errs.push('inline.' + k + '.template must be a string');
      } else if (rule.template.indexOf('{label}') === -1 || rule.template.indexOf('{href}') === -1) {
        errs.push('inline.' + k + '.template must contain both {label} and {href} (it drops content otherwise)');
      }
    }
  });

  // (4) linePrefix is a string (when present)
  Object.keys(blocks).forEach(function (k) {
    var rule = blocks[k];
    if (rule && rule.linePrefix !== undefined && typeof rule.linePrefix !== 'string') {
      errs.push('blocks.' + k + '.linePrefix must be a string');
    }
  });

  if (errs.length) throw new Error('invalid ruleset: ' + errs.join('; '));
  return ruleset;
}

var api = {
  render: render,
  renderSource: renderSource,
  RULESETS: RULESETS,
  resolveRuleset: resolveRuleset,
  validateRuleset: validateRuleset
};
if (typeof module !== 'undefined' && module.exports) module.exports = api;

// ---- selftest ---------------------------------------------------------------
function selftest() {
  var fails = [];
  function ck(name, cond) { if (!cond) fails.push(name); }
  var src = '# Title\n\n**b** *i* `c`\n\n> quote\n> line two\n\n- one\n- two\n\n1. a\n2. b\n\n```\nx = 1\n```\n\n[ok](https://e.com) [bad](javascript:x)';

  // determinism
  var r1 = renderSource(src, RULESETS.reddit).text;
  var r2 = renderSource(src, RULESETS.reddit).text;
  ck('deterministic', r1 === r2);
  ck('re-render stable', renderSource(r1, RULESETS.reddit).text === renderSource(renderSource(r1, RULESETS.reddit).text, RULESETS.reddit).text);

  // reddit dialect deltas
  ck('reddit heading->bold', r1.indexOf('**Title**') !== -1 && r1.indexOf('# Title') === -1);
  ck('reddit fence->indent', r1.indexOf('    x = 1') !== -1);
  ck('reddit blank-line blocks', /\n\n/.test(r1));
  ck('unsafe href dropped', r1.indexOf('javascript:') === -1 && r1.indexOf('bad') !== -1);
  ck('safe href kept', r1.indexOf('[ok](https://e.com)') !== -1);

  // reddit-post variant keeps hash + fence
  var rp = renderSource('# Title\n\n```\nx\n```', RULESETS['reddit-post']).text;
  ck('reddit-post keeps hash', rp.indexOf('# Title') !== -1);
  ck('reddit-post keeps fence', rp.indexOf('```') !== -1);

  // plain strips markup
  var pl = renderSource('# T\n\n**b** *i* [t](https://e.com)', RULESETS.plain).text;
  ck('plain strips heading marker', pl.indexOf('#') === -1);
  ck('plain strips strong', pl.indexOf('**') === -1 && pl.indexOf('b') !== -1);
  ck('plain link labelHref', pl.indexOf('t (https://e.com)') !== -1);

  // holes are reported, never crash — a custom ruleset with an empty inline table
  var holed = render(md.parse('**x** *y*'), { name: 't', blockJoin: '\n\n', blocks: {}, inline: {} });
  ck('hole render does not crash', typeof holed.text === 'string');

  // custom ruleset via object
  var custom = { name: 'shout', blockJoin: '\n\n', blocks: { paragraph: {} },
                 inline: { strong: { wrap: ['[', ']'] }, em: { wrap: ['', ''] },
                           codeSpan: { wrap: ['`', '`'] }, link: { form: 'label' } } };
  ck('custom wrap applied', renderSource('**hi**', custom).text.indexOf('[hi]') === 0);

  // ruleset-load validator — the four decidable structural predicates
  function throws(fn) { try { fn(); return false; } catch (e) { return true; } }
  // every built-in ruleset passes its own validator (a real gate must not reject the good)
  ck('validator passes built-ins', Object.keys(RULESETS).every(function (k) {
    return !throws(function () { validateRuleset(RULESETS[k]); });
  }));
  // (1) wrap must be a 2-element pair
  ck('validator rejects bad wrap', throws(function () {
    validateRuleset({ blockJoin: '\n\n', inline: { strong: { wrap: ['*'] } } });
  }));
  ck('validator accepts good wrap', !throws(function () {
    validateRuleset({ blockJoin: '\n\n', inline: { strong: { wrap: ['*', '*'] } } });
  }));
  // (2) blockJoin must be a string
  ck('validator rejects non-string blockJoin', throws(function () {
    validateRuleset({ blockJoin: 2, inline: {} });
  }));
  // (3) a template must contain {label} and {href}
  ck('validator rejects template missing href', throws(function () {
    validateRuleset({ inline: { link: { form: 'inline', template: '[{label}]' } } });
  }));
  ck('validator accepts full template', !throws(function () {
    validateRuleset({ inline: { link: { form: 'inline', template: '[{label}]({href})' } } });
  }));
  // (4) linePrefix must be a string
  ck('validator rejects non-string linePrefix', throws(function () {
    validateRuleset({ blocks: { blockquote: { linePrefix: 4 } } });
  }));
  // a non-object ruleset is rejected outright
  ck('validator rejects non-object', throws(function () { validateRuleset(null); }) &&
                                     throws(function () { validateRuleset([1, 2]); }));

  if (fails.length) { process.stderr.write('SELFTEST FAIL: ' + fails.join('; ') + '\n'); process.exit(1); }
  process.stdout.write('SELFTEST OK (' + 23 + ' checks)\n'); process.exit(0);
}

// ---- CLI --------------------------------------------------------------------
if (typeof require !== 'undefined' && typeof module !== 'undefined' && require.main === module) {
  var fs = require('fs');
  var args = process.argv.slice(2);

  if (args.indexOf('--help') !== -1 || args.length === 0) {
    process.stdout.write(
      'dialect — render Markdown into a target dialect via a declarative ruleset\n\n' +
      '  dialect.js --ruleset reddit IN.md [-o OUT.md]   built-in ruleset by name\n' +
      '  dialect.js --ruleset ./my.json IN.md            ruleset from a JSON file\n' +
      '  dialect.js --ruleset reddit -                   read stdin, write stdout\n' +
      '  dialect.js --list                               list built-in rulesets\n' +
      '  dialect.js --selftest                           determinism + dialect self-test\n' +
      '  dialect.js --help\n\n' +
      'Built-in rulesets: ' + Object.keys(RULESETS).join(', ') + '\n\n' +
      'Scope (the edge): the declarative ruleset covers the ~95% of Markdown whose\n' +
      'emission is a flat per-node substitution. It STOPS at `table`: column\n' +
      'alignment is logic, not a flat substitution, so a table target needs a code\n' +
      'writer, not a ruleset. A table node renders its cell content bare and is\n' +
      'reported as a hole — never silently mangled.\n'
    );
    process.exit(0);
  }
  if (args.indexOf('--selftest') !== -1) selftest();
  if (args.indexOf('--list') !== -1) {
    Object.keys(RULESETS).forEach(function (k) {
      var d = RULESETS[k].desc ? '  —  ' + RULESETS[k].desc : '';
      process.stdout.write(k + d + '\n');
    });
    process.exit(0);
  }

  var rulesetArg = 'reddit', inFile = null, outFile = null, useStdin = false, i;
  for (i = 0; i < args.length; i++) {
    if (args[i] === '--ruleset') rulesetArg = args[++i];
    else if (args[i] === '-o' || args[i] === '--out') outFile = args[++i];
    else if (args[i] === '-') useStdin = true;
    else if (args[i].indexOf('--') === 0) { /* ignore */ }
    else inFile = args[i];
  }

  try {
    var ruleset;
    if (rulesetArg && (rulesetArg.indexOf('/') !== -1 || rulesetArg.indexOf('.json') !== -1)) {
      ruleset = JSON.parse(fs.readFileSync(rulesetArg, 'utf8'));
      validateRuleset(ruleset); // loud-stop on a structurally malformed external ruleset
    } else {
      ruleset = RULESETS[rulesetArg];
      if (!ruleset) throw new Error('unknown ruleset "' + rulesetArg + '" (try --list)');
    }

    function emit(source) {
      var res = render(md.parse(source), ruleset);
      if (res.holes.length) process.stderr.write('note: ruleset has no rule for node type(s): ' + res.holes.join(', ') + ' (rendered content bare)\n');
      if (outFile) { fs.writeFileSync(outFile, res.text); process.stderr.write('wrote ' + outFile + ' (' + res.text.length + ' bytes)\n'); }
      else if (useStdin) process.stdout.write(res.text);
      else {
        var def = (inFile ? inFile.replace(/\.md$/i, '') : 'dialect-output') + '.' + (ruleset.name || 'out') + '.md';
        fs.writeFileSync(def, res.text); process.stderr.write('wrote ' + def + ' (' + res.text.length + ' bytes)\n');
      }
    }

    if (useStdin) {
      var chunks = [];
      process.stdin.on('data', function (d) { chunks.push(d); });
      process.stdin.on('end', function () { emit(Buffer.concat(chunks).toString('utf8')); process.exit(0); });
    } else {
      if (!inFile) throw new Error('no input file (use - for stdin, or --help)');
      emit(fs.readFileSync(inFile, 'utf8'));
      process.exit(0);
    }
  } catch (e) {
    process.stderr.write('dialect: ' + (e && e.message ? e.message : String(e)) + '\n');
    process.exit(1);
  }
}
test_dialect.js184 lineson GitHub →
#!/usr/bin/env node
/* test_dialect.js — the golden oracle corpus for the dialect gift.

   The oracle rule: every expected output here is HAND-AUTHORED from the ruleset
   rules (read off the schema, computed by hand), NOT produced by running the gift.
   The gift must AGREE with the independent expectation. The known-bad half is
   drawn from the real failure shapes the engine promises to prevent — a dialect
   silently keeping markup it should strip, an unsafe href surviving, a malformed
   ruleset loading without complaint — not author-invented pleasantness.

   Three mutation bites prove the oracle has teeth: each asserts that a plausible
   WRONG engine behaviour WOULD be caught here (the check is not vacuously green).

   Run:  node test_dialect.js
   Exit 0 all-green / 1 any-red.
*/
'use strict';
var D = require('./dialect.js');

var checks = [];
function record(name, ok, detail) { checks.push({ name: name, ok: !!ok, detail: detail || '' }); }
function eq(name, got, want) {
  record(name, got === want, got === want ? '' : 'got ' + JSON.stringify(got) + ' want ' + JSON.stringify(want));
}
function ok(name, cond, detail) { record(name, cond, detail); }
function throws(fn) { try { fn(); return false; } catch (e) { return true; } }

var R = D.RULESETS;

/* ============================================================
   1. REDDIT COMMENT dialect — hand-authored expectations.
      heading -> **bold** line · fence -> 4-space indent ·
      blocks blank-line separated · unsafe href dropped to label.
   ============================================================ */
(function reddit() {
  var src = '# Title\n\n**b** *i* `c`\n\n```\nx = 1\n```\n\n[ok](https://e.com) [bad](javascript:x)';
  var out = D.renderSource(src, R.reddit).text;
  // heading became a bold line, not an atx line
  ok('reddit heading->bold', out.indexOf('**Title**') !== -1 && out.indexOf('# Title') === -1, out);
  // inline marks preserved as Reddit markup
  ok('reddit keeps ** * `', out.indexOf('**b**') !== -1 && out.indexOf('*i*') !== -1 && out.indexOf('`c`') !== -1, out);
  // fenced code became 4-space indent (no ``` in a reddit COMMENT)
  ok('reddit fence->indent', out.indexOf('    x = 1') !== -1 && out.indexOf('```') === -1, out);
  // link safety: unsafe href dropped to its label, safe href kept inline
  ok('reddit unsafe href dropped', out.indexOf('javascript:') === -1 && out.indexOf('bad') !== -1, out);
  ok('reddit safe href inline', out.indexOf('[ok](https://e.com)') !== -1, out);
})();

/* ============================================================
   2. REDDIT-POST variant — keeps # headings and ``` fences.
   ============================================================ */
(function redditPost() {
  var out = D.renderSource('# Title\n\n```\nx\n```', R['reddit-post']).text;
  ok('reddit-post keeps # heading', out.indexOf('# Title') !== -1, out);
  ok('reddit-post keeps ``` fence', out.indexOf('```') !== -1, out);
})();

/* ============================================================
   3. PLAIN dialect — every mark stripped; link -> "label (href)".
   ============================================================ */
(function plain() {
  var out = D.renderSource('# T\n\n**b** *i* [t](https://e.com)', R.plain).text;
  ok('plain strips heading marker', out.indexOf('#') === -1 && out.indexOf('T') !== -1, out);
  ok('plain strips strong/em', out.indexOf('**') === -1 && out.indexOf('b') !== -1 && out.indexOf('i') !== -1, out);
  ok('plain link -> label (href)', out.indexOf('t (https://e.com)') !== -1, out);
})();

/* ============================================================
   4. DETERMINISM — a pure fold: same input twice -> byte-identical;
      re-rendering the output is stable.
   ============================================================ */
(function determinism() {
  var src = '# H\n\n**b**\n\n- one\n- two\n\n1. a\n2. b';
  var a = D.renderSource(src, R.reddit).text;
  var b = D.renderSource(src, R.reddit).text;
  eq('deterministic (folds-twice-identical)', a, b);
  var once = D.renderSource(a, R.reddit).text;
  var twice = D.renderSource(once, R.reddit).text;
  eq('re-render stable', once, twice);
  // ordered-list markers hand-computed: "1. a" then "2. b"
  ok('ordered list numbering', a.indexOf('1. a') !== -1 && a.indexOf('2. b') !== -1, a);
})();

/* ============================================================
   5. HOLES — an unknown node type (block or inline) renders its
      content bare, is REPORTED in .holes, and never crashes or
      silently drops. Hand-build an AST with bogus node types so the
      hole path is exercised directly (no parser needed).
   ============================================================ */
(function holes() {
  var ast = {
    type: 'document',
    children: [
      { type: 'paragraph', children: [
        { type: 'text', value: 'a' },
        { type: 'mystery', value: 'b' }            // unknown INLINE type
      ] },
      { type: 'weirdBlock', children: [
        { type: 'text', value: 'c' }
      ] }                                           // unknown BLOCK type
    ]
  };
  var res = D.render(ast, R.reddit);
  ok('hole render does not crash', typeof res.text === 'string', JSON.stringify(res));
  ok('unknown inline reported', res.holes.indexOf('mystery') !== -1, JSON.stringify(res.holes));
  ok('unknown block reported', res.holes.indexOf('weirdBlock') !== -1, JSON.stringify(res.holes));
  ok('hole content preserved bare', res.text.indexOf('a') !== -1 && res.text.indexOf('b') !== -1 && res.text.indexOf('c') !== -1, res.text);
})();

/* ============================================================
   6. RULESET-LOAD VALIDATOR — the four decidable predicates.
      Each malformed ruleset must LOUD-STOP (throw); each well-formed
      one must pass. A real gate rejects the bad and admits the good.
   ============================================================ */
(function validator() {
  // every built-in passes its own validator
  ok('validator admits all built-ins', Object.keys(R).every(function (k) {
    return !throws(function () { D.validateRuleset(R[k]); });
  }));
  // (1) wrap must be a 2-element pair
  ok('(1) rejects 1-element wrap', throws(function () {
    D.validateRuleset({ inline: { strong: { wrap: ['*'] } } });
  }));
  ok('(1) rejects 3-element wrap', throws(function () {
    D.validateRuleset({ inline: { strong: { wrap: ['*', '*', '*'] } } });
  }));
  ok('(1) admits 2-element wrap', !throws(function () {
    D.validateRuleset({ inline: { strong: { wrap: ['*', '*'] } } });
  }));
  // (2) blockJoin must be a string
  ok('(2) rejects numeric blockJoin', throws(function () {
    D.validateRuleset({ blockJoin: 3 });
  }));
  // (3) template must contain {label} and {href}
  ok('(3) rejects template missing {href}', throws(function () {
    D.validateRuleset({ inline: { link: { template: '[{label}]' } } });
  }));
  ok('(3) rejects template missing {label}', throws(function () {
    D.validateRuleset({ inline: { link: { template: '({href})' } } });
  }));
  ok('(3) admits full template', !throws(function () {
    D.validateRuleset({ inline: { link: { template: '[{label}]({href})' } } });
  }));
  // (4) linePrefix must be a string
  ok('(4) rejects numeric linePrefix', throws(function () {
    D.validateRuleset({ blocks: { blockquote: { linePrefix: 0 } } });
  }));
  // non-object rejected outright
  ok('rejects non-object ruleset', throws(function () { D.validateRuleset(null); }));
})();

/* ============================================================
   7. MUTATION BITES — prove the oracle has teeth.
      Each simulates a WRONG engine output and asserts the check
      above WOULD have caught it (the green is not vacuous).
   ============================================================ */
(function mutationBites() {
  // Bite 1: if reddit KEPT the '#' heading (wrong for a comment), the
  //         'reddit heading->bold' check's condition would be false.
  var wrong1 = '# Title\n\n**b**\n';
  ok('BITE reddit-heading check has teeth',
     !(wrong1.indexOf('**Title**') !== -1 && wrong1.indexOf('# Title') === -1),
     'a mangled engine that kept # would slip past a toothless check');

  // Bite 2: if an unsafe href SURVIVED, the drop check would be false.
  var wrong2 = '[bad](javascript:x)\n';
  ok('BITE unsafe-href check has teeth',
     !(wrong2.indexOf('javascript:') === -1 && wrong2.indexOf('bad') !== -1),
     'a mangled engine that kept javascript: would slip past a toothless check');

  // Bite 3: if the validator were a no-op (never threw), the reject checks
  //         would all be false. Assert a genuinely-bad ruleset still throws.
  ok('BITE validator check has teeth',
     throws(function () { D.validateRuleset({ inline: { strong: { wrap: ['x'] } } }); }),
     'a no-op validator would fail this bite');
})();

/* ---- report ---------------------------------------------------------------- */
var reds = checks.filter(function (c) { return !c.ok; });
checks.forEach(function (c) {
  process.stdout.write((c.ok ? 'ok   ' : 'FAIL ') + c.name + (!c.ok && c.detail ? '  — ' + c.detail : '') + '\n');
});
process.stdout.write('\n' + (reds.length ? 'FAIL' : 'OK') + ' (' + checks.length + ' checks, ' + reds.length + ' red)\n');
process.exit(reds.length ? 1 : 0);
Take the whole folder → MIT Zero dependencies, Node or browser, deterministic (folds beside the markdown gift)