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
Tasks In, One Gantt SVG Outrender← all gifts

Gantt-sink

gantt-sink turns a stream of (start,end,label) tasks into one standalone, deterministic Gantt SVG with zero dependencies — one horizontal <rect> bar per task, top to bottom in input order, each labeled. It renders no axes, dates, gridlines, dependency arrows, or legend — just the bars — so it drops straight into a README, a report, or a CI comment where a project-management tool would be overkill. It is the render-sink cluster's first gift to place caller text in the output (the label), and it closes that one injection surface deliberately: every label is XML-escaped, so caller text can never break out of markup. Same tasks in → byte-identical SVG out.

The honest edge
gantt-sink is a timeline-bar primitive, not a project-management tool: one <rect> bar per task in INPUT ORDER, each with a <text> label, no axes/dates/dependency-arrows/legend, no fonts/script. Unlike its numbers-only siblings it DOES place caller text — the label — so it carries the one injection surface they avoid; that surface is closed DELIBERATELY: every label is XML-escaped (& < > " '), so a <script> label renders only as escaped entities, never a real tag. Bar colors come from a fixed named palette (index = row), never caller input. A non-finite start/end, an end before its start, or a non-string label is a hard error. Same tasks in → byte-identical SVG out.
Run it
printf '[0,3,"design"]\n[2,5,"build"]\n' | node gantt-sink.js # a two-row timeline SVG (--width/--row/--pad · --min/--max · --palette loop|mono|warm|cool) test_gantt-sink.js (13/13, out-of-band hand-computed SVG oracle incl. hostile-label escaping probe) + Plumb conformance GREEN (I1-I5, I3=escaped-text safety, signed, clock-independent) Node / browser, no dependencies
The code — every file that ships
gantt-sink.js270 lineson GitHub →
#!/usr/bin/env node
/*
 * gantt-sink — turn (start,end,label) tasks into one standalone, deterministic Gantt SVG, no deps.
 * MIT · zero-dependency · standalone gift · lane: sink (consumes data, emits an artifact).
 *
 * THE PRINTED EDGE (read before trusting the output):
 *   This is a timeline-bar primitive, not a project-management tool. It draws one horizontal
 *   <rect> bar per task, top to bottom in INPUT ORDER, inside a plain <svg> frame, with each
 *   task's LABEL as one <text> element beside its bar. It renders NO axes, gridlines, tick
 *   marks, date labels, dependency arrows, legend, or interactivity, and it embeds NO fonts
 *   and NO <script>. Unlike its render-sink siblings, gantt-sink DOES place caller text in the
 *   output — the label — so it carries the one injection surface the numbers-only gifts avoid.
 *   That surface is closed DELIBERATELY: every label is XML-escaped (& < > " '), so a caller
 *   string can NEVER break out of <text> content into markup. Bar colors are NOT free-form
 *   input — bars are painted from a fixed, named palette (index = row), so no attacker-chosen
 *   attribute string can appear. A non-finite start/end, an end before its start, or a
 *   non-string label is a HARD ERROR, never silently dropped or guessed. Output is a PURE
 *   FUNCTION of the input: same tasks in → byte-identical SVG out.
 *
 * USAGE:
 *   printf '[0,3,"design"]\n[2,5,"build"]\n' | node gantt-sink.js         # two-row timeline
 *   printf '{"start":0,"end":4,"label":"spec"}\n' | node gantt-sink.js     # object tasks
 *   echo '{"tasks":[[0,2,"a"],[1,4,"b"]],"palette":"cool"}' | node gantt-sink.js
 *   node gantt-sink.js --help
 *
 * INPUT (stdin): either
 *   - JSONL — one task per line, each `[start,end,label]` or `{"start":S,"end":E,"label":"…"}`
 *   - a single JSON spec object { tasks, min, max, width, row, pad, palette }
 *   start,end are finite numbers (timeline positions, end >= start); label is a STRING.
 *   CLI flags (--width --row --pad --min --max --palette) OVERRIDE object fields.
 *
 * OUTPUT (stdout): one SVG document string (UTF-8), trailing newline. No tasks → an empty frame.
 *
 * DETERMINISM: fixed 3-decimal coordinate precision, stable attribute order, rows emitted in
 *   input order, bar color indexed by row from a fixed palette, every label XML-escaped. No
 *   wall-clock, no randomness. Same tasks → same bytes, in Node or a browser.
 * PORTABILITY: pure JS on plain arrays/strings — identical in Node and the browser.
 */
'use strict';

// ---- palettes (the ONLY source of bar color; no free-form color input) ----------
var PALETTES = {
  loop: ['#2f6f8f', '#c25b3a', '#4a8a52', '#8a6d3b', '#6d4a8a', '#3b6d8a'],
  mono: ['#111111', '#555555', '#999999', '#bbbbbb'],
  warm: ['#c25b3a', '#d98a3a', '#b23b3b', '#8a5a2b'],
  cool: ['#2f6f8f', '#4a8a8a', '#3b5a8a', '#5a6d8a']
};
var TEXT_FILL = '#111111';   // fixed label color, never caller input
var FONT_SIZE = 11;          // fixed label size (a number, not an embedded font)
var DEFAULTS = { width: 204, row: 20, pad: 2, palette: 'loop' };

// ---- deterministic number formatting (verbatim from svg-sink) -------------------
var PRECISION = 3, SCALE = 1000;
function num(x) {
  var r = Math.round(x * SCALE) / SCALE;
  var s = r.toFixed(PRECISION);
  s = s.replace(/\.?0+$/, '');
  return (s === '' || s === '-0') ? '0' : s;
}

// ---- XML escaping — THE deliberate injection surface, closed here ----------------
// Every caller label passes through this before entering <text> content. Order matters:
// the ampersand is replaced FIRST so the entities introduced below are not double-escaped.
function xmlEscape(s) {
  return String(s)
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}

// ---- validation helpers --------------------------------------------------------
function finiteNum(v, i, which) {
  if (typeof v !== 'number' || !isFinite(v)) {
    throw new Error('task[' + i + '] ' + which + ' is not a finite number: ' + JSON.stringify(v));
  }
  return v;
}
function posInt(v, dflt, name) {
  if (v == null) return dflt;
  var n = Number(v);
  if (!isFinite(n) || Math.floor(n) !== n || n <= 0) throw new Error(name + ' must be a positive integer, got ' + JSON.stringify(v));
  return n;
}
function numOrThrow(v, name) {
  var n = Number(v);
  if (typeof n !== 'number' || !isFinite(n)) throw new Error(name + ' must be a finite number, got ' + JSON.stringify(v));
  return n;
}

// ---- one task -> {start,end,label}  (accepts [start,end,label] or {start,end,label}) ----
function asTask(raw, i) {
  var start, end, label;
  if (Array.isArray(raw)) {
    if (raw.length !== 3) throw new Error('task[' + i + '] array must be [start,end,label] (3 fields), got ' + JSON.stringify(raw));
    start = raw[0]; end = raw[1]; label = raw[2];
  } else if (raw && typeof raw === 'object') {
    if (!('start' in raw) || !('end' in raw) || !('label' in raw)) throw new Error('task[' + i + '] object must have start,end,label, got ' + JSON.stringify(raw));
    start = raw.start; end = raw.end; label = raw.label;
  } else {
    throw new Error('task[' + i + '] must be [start,end,label] or {start,end,label}, got ' + JSON.stringify(raw));
  }
  finiteNum(start, i, 'start'); finiteNum(end, i, 'end');
  if (end < start) throw new Error('task[' + i + '] end (' + end + ') is before start (' + start + ')');
  if (typeof label !== 'string') throw new Error('task[' + i + '] label must be a string, got ' + JSON.stringify(label));
  return { start: start, end: end, label: label };
}

// ---- scale (shared timeline domain across all tasks) ---------------------------
function domain(tasks, min, max) {
  var lo = (min != null) ? Number(min) : Infinity;
  var hi = (max != null) ? Number(max) : -Infinity;
  if (min == null || max == null) {
    for (var i = 0; i < tasks.length; i++) {
      if (min == null && tasks[i].start < lo) lo = tasks[i].start;
      if (max == null && tasks[i].end > hi) hi = tasks[i].end;
    }
  }
  if (!isFinite(lo)) lo = 0;
  if (!isFinite(hi)) hi = 0;
  if (lo === hi) { lo -= 1; hi += 1; } // all-instant tasks -> unit window, never a divide-by-0
  return { lo: lo, hi: hi };
}

// ---- input normalization -------------------------------------------------------
function normalize(input, flags) {
  var spec = {};
  var tasksRaw;
  if (input && !Array.isArray(input) && typeof input === 'object') {
    tasksRaw = input.tasks;
    if (input.min     != null) spec.min     = input.min;
    if (input.max     != null) spec.max     = input.max;
    if (input.width   != null) spec.width   = input.width;
    if (input.row     != null) spec.row     = input.row;
    if (input.pad     != null) spec.pad     = input.pad;
    if (input.palette != null) spec.palette = input.palette;
  } else {
    tasksRaw = input;
  }
  flags = flags || {};
  for (var k in flags) if (flags[k] != null) spec[k] = flags[k];

  if (tasksRaw == null) tasksRaw = [];
  if (!Array.isArray(tasksRaw)) throw new Error('input has no tasks (expected [[start,end,label],...] or a {tasks:...} object)');
  spec.tasks   = tasksRaw.map(asTask);
  spec.width   = posInt(spec.width, DEFAULTS.width, 'width');
  spec.row     = posInt(spec.row,   DEFAULTS.row,   'row');
  spec.pad     = (spec.pad != null) ? posIntOrZero(spec.pad, 'pad') : DEFAULTS.pad;
  spec.palette = (spec.palette != null) ? String(spec.palette) : DEFAULTS.palette;
  if (spec.min != null) spec.min = numOrThrow(spec.min, 'min');
  if (spec.max != null) spec.max = numOrThrow(spec.max, 'max');
  if (spec.min != null && spec.max != null && spec.min >= spec.max) {
    throw new Error('min must be < max (got min=' + spec.min + ', max=' + spec.max + ')');
  }
  if (!PALETTES[spec.palette]) throw new Error('unknown palette "' + spec.palette + '" (expected ' + Object.keys(PALETTES).join(' | ') + ')');
  if (spec.width <= 2 * spec.pad) throw new Error('width too small for pad (need width > 2*pad)');
  return spec;
}
function posIntOrZero(v, name) {
  var n = Number(v);
  if (!isFinite(n) || Math.floor(n) !== n || n < 0) throw new Error(name + ' must be a non-negative integer, got ' + JSON.stringify(v));
  return n;
}

// ---- core: renderGantt(spec) -> string  (pure, the whole gift) ------------------
function gantt(input, flags) {
  var spec = normalize(input, flags);
  var tasks = spec.tasks, W = spec.width, rowH = spec.row, pad = spec.pad;
  var n = tasks.length;
  var H = 2 * pad + n * rowH;
  var innerW = W - 2 * pad;
  var pal = PALETTES[spec.palette];
  var dom = domain(tasks, spec.min, spec.max);
  function X(t) { return pad + ((t - dom.lo) / (dom.hi - dom.lo)) * innerW; }

  var body = [];
  for (var i = 0; i < n; i++) {          // rows in INPUT ORDER (a gantt is an ordered list)
    var t = tasks[i];
    var rowTop = pad + i * rowH;
    var x = X(t.start), w = X(t.end) - X(t.start);
    var barY = rowTop + 3, barH = rowH - 6;
    var color = pal[i % pal.length];
    body.push('<rect x="' + num(x) + '" y="' + num(barY) + '" width="' + num(w) +
              '" height="' + num(barH) + '" fill="' + color + '" />');
    // the label — THE injection surface — escaped before it enters <text> content.
    body.push('<text x="' + num(x + 2) + '" y="' + num(rowTop + rowH - 6) +
              '" font-size="' + FONT_SIZE + '" fill="' + TEXT_FILL + '">' + xmlEscape(t.label) + '</text>');
  }

  var open = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + W + ' ' + H + '" width="' + W + '" height="' + H + '">';
  return open + '\n' + (body.length ? body.join('\n') + '\n' : '') + '</svg>\n';
}

// ---- exports (browser attach · require · direct run) ---------------------------
if (typeof window !== 'undefined') {
  window.LoopGifts = window.LoopGifts || {};
  window.LoopGifts['gantt-sink'] = { gantt: gantt, PALETTES: PALETTES, xmlEscape: xmlEscape };
}
if (typeof module !== 'undefined' && module.exports) {
  module.exports = { gantt: gantt, num: num, normalize: normalize, domain: domain, xmlEscape: xmlEscape, PALETTES: PALETTES, TEXT_FILL: TEXT_FILL };
}

// ---- cli -----------------------------------------------------------------------
var HELP =
'gantt-sink — (start,end,label) tasks -> a standalone, deterministic Gantt SVG, zero deps.\n\n' +
"  printf '[0,3,\"design\"]\\n[2,5,\"build\"]\\n' | node gantt-sink.js\n" +
"  printf '{\"start\":0,\"end\":4,\"label\":\"spec\"}\\n' | node gantt-sink.js\n" +
"  echo '{\"tasks\":[[0,2,\"a\"],[1,4,\"b\"]]}' | node gantt-sink.js --palette cool\n\n" +
'INPUT (stdin): JSONL of [start,end,label] or {start,end,label} tasks (one per line), OR a\n' +
'{tasks,...} spec object. Flags (override object fields): --width N  --row N  --pad N\n' +
'                                                          --min N  --max N  --palette loop|mono|warm|cool\n\n' +
'One <rect> bar + one <text> label per task, in input order; no axes/dates/arrows/legend. Bar\n' +
'colors come from a fixed named palette (not caller input). Labels are XML-escaped — caller text\n' +
'can never break out of markup. Non-finite start/end, end<start, or a non-string label -> error.\n';

function parseFlags(argv) {
  var f = {};
  for (var i = 0; i < argv.length; i++) {
    var a = argv[i];
    if (a === '--width')   f.width   = Number(argv[++i]);
    else if (a === '--row') f.row    = Number(argv[++i]);
    else if (a === '--pad') f.pad    = Number(argv[++i]);
    else if (a === '--min') f.min    = Number(argv[++i]);
    else if (a === '--max') f.max    = Number(argv[++i]);
    else if (a === '--palette') f.palette = argv[++i];
  }
  return f;
}

function parseStdin(raw) {
  var trimmed = raw.trim();
  if (trimmed === '') return { tasks: [] };
  if (trimmed.charAt(0) === '{') {
    try {
      var obj = JSON.parse(trimmed);
      if (obj && !Array.isArray(obj) && typeof obj === 'object' && 'tasks' in obj) return obj;
    } catch (e) { /* fall through to JSONL */ }
  }
  var tasks = [];
  var lines = trimmed.split('\n');
  for (var i = 0; i < lines.length; i++) {
    var ln = lines[i].trim();
    if (ln === '') continue;
    var task;
    try { task = JSON.parse(ln); }
    catch (e) { throw new Error('line ' + (i + 1) + ' is not valid JSON: ' + e.message); }
    tasks.push(task);
  }
  return { tasks: tasks };
}

function main() {
  var argv = process.argv.slice(2);
  if (argv.indexOf('--help') !== -1 || argv.indexOf('-h') !== -1) { process.stdout.write(HELP); return; }
  var flags = parseFlags(argv);
  var chunks = '';
  process.stdin.setEncoding('utf8');
  process.stdin.on('data', function (d) { chunks += d; });
  process.stdin.on('end', function () {
    var input;
    try { input = parseStdin(chunks); }
    catch (e) { process.stderr.write('gantt-sink: ' + e.message + '\n'); process.exitCode = 1; return; }
    var svg;
    try { svg = gantt(input, flags); }
    catch (e) { process.stderr.write('gantt-sink: ' + e.message + '\n'); process.exitCode = 1; return; }
    process.stdout.write(svg);
  });
}
if (typeof require !== 'undefined' && require.main === module) { main(); }
test_gantt-sink.js134 lineson GitHub →
#!/usr/bin/env node
/*
 * test_gantt-sink.js — drift-check battery for the gantt-sink gift.
 *
 * THE ORACLE IS OUT-OF-BAND BY CONSTRUCTION. The expected SVG documents below are hand-computed
 * from the documented math — xScale(t) = pad + (t-lo)/(hi-lo)*innerW (W=204, pad=2, innerW=200,
 * rowH=20), rows in input order, bar color = PALETTES.loop[row], label XML-escaped — written
 * independently of the emitter, not captured from its output. A build cannot certify itself, so
 * the expected strings are the author's fact. The escaping vector is the CHAR-OF-THIS-GIFT: the
 * one caller-text surface, closed by xmlEscape; a <script>/<b> label must appear ONLY as escaped
 * entities, never as raw markup.
 *
 * Run: node test_gantt-sink.js   (exit 0 = all pass; nonzero = a named failure)
 */
'use strict';
const assert = require('assert');
const { gantt } = require('./gantt-sink.js');

let n = 0, passed = 0;
function check(name, fn) {
  n++;
  try { fn(); passed++; console.log('  ok   ' + name); }
  catch (e) { console.log('  FAIL ' + name + '  — ' + e.message); process.exitCode = 1; }
}

// loop palette: #2f6f8f #c25b3a #4a8a52 #8a6d3b #6d4a8a #3b6d8a ; text #111111 ; font-size 11
const TWO =
  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 204 44" width="204" height="44">\n' +
  '<rect x="2" y="5" width="120" height="14" fill="#2f6f8f" />\n' +
  '<text x="4" y="16" font-size="11" fill="#111111">design</text>\n' +
  '<rect x="82" y="25" width="120" height="14" fill="#c25b3a" />\n' +
  '<text x="84" y="36" font-size="11" fill="#111111">build</text>\n' +
  '</svg>\n';

// 1 — two tasks: auto-domain [0,5] -> xScale(t)=2+t*40; rows in input order, palette by row.
check('two tasks: [[0,3,design],[2,5,build]] -> ordered bars + labels', () => {
  assert.strictEqual(gantt({ tasks: [[0,3,"design"],[2,5,"build"]] }), TWO);
});

// 2 — THE escaping probe: a hostile label renders ONLY as entities, never as raw markup.
check('escaping: label <b>&"\' -> entities only, no raw markup', () => {
  const exp =
    '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 204 24" width="204" height="24">\n' +
    '<rect x="2" y="5" width="200" height="14" fill="#2f6f8f" />\n' +
    '<text x="4" y="16" font-size="11" fill="#111111">&lt;b&gt;&amp;&quot;&#39;</text>\n' +
    '</svg>\n';
  const out = gantt({ tasks: [[0,1,"<b>&\"'"]] });
  assert.strictEqual(out, exp);
  // security assertions: no raw label markup leaks into the output.
  assert.ok(!out.includes('<b>'), 'raw <b> must never appear');
  assert.ok(out.includes('&lt;b&gt;'), 'label < > must be escaped');
});

// 2b — a <script> label cannot break out (defense-in-depth, the classic attack).
check('escaping: <script> label never appears as a real tag', () => {
  const out = gantt({ tasks: [[0,1,"<script>alert(1)</script>"]] });
  assert.ok(!out.includes('<script'), 'no raw <script tag');
  assert.ok(out.includes('&lt;script&gt;alert(1)&lt;/script&gt;'), 'script label fully escaped');
});

// 3 — milestone (start==end): flat window -> zero-width bar, no divide-by-0.
check('milestone: [[2,2,ship]] -> zero-width bar centered', () => {
  const exp =
    '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 204 24" width="204" height="24">\n' +
    '<rect x="102" y="5" width="0" height="14" fill="#2f6f8f" />\n' +
    '<text x="104" y="16" font-size="11" fill="#111111">ship</text>\n' +
    '</svg>\n';
  assert.strictEqual(gantt({ tasks: [[2,2,"ship"]] }), exp);
});

// 4 — empty input -> a valid, empty frame (height = 2*pad).
check('empty: {tasks:[]} -> empty <svg> frame', () => {
  assert.strictEqual(gantt({ tasks: [] }),
    '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 204 4" width="204" height="4">\n</svg>\n');
});

// 5 — pinned domain + object-form task render identically to the array form.
check('pinned + object task: {start,end,label} on [0,4]', () => {
  const exp =
    '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 204 24" width="204" height="24">\n' +
    '<rect x="2" y="5" width="100" height="14" fill="#2f6f8f" />\n' +
    '<text x="4" y="16" font-size="11" fill="#111111">a</text>\n' +
    '</svg>\n';
  assert.strictEqual(gantt({ tasks: [{start:0,end:2,label:"a"}], min:0, max:4 }), exp);
});

// 6 — empty label string is allowed (an empty <text> element).
check('empty label -> empty <text> element', () => {
  const exp =
    '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 204 24" width="204" height="24">\n' +
    '<rect x="2" y="5" width="200" height="14" fill="#2f6f8f" />\n' +
    '<text x="4" y="16" font-size="11" fill="#111111"></text>\n' +
    '</svg>\n';
  assert.strictEqual(gantt({ tasks: [[0,1,""]] }), exp);
});

// 7 — determinism: same tasks -> byte-identical output, twice.
check('determinism: repeated render is byte-identical', () => {
  const t = { tasks: [[0,2,"a"],[1,5,"b"],[3,4,"c"]] };
  assert.strictEqual(gantt(t), gantt(t));
});

// 8 — non-finite start/end is a hard, named error.
check('non-finite start/end throws, naming the task', () => {
  assert.throws(() => gantt({ tasks: [[NaN,1,"a"]] }), /task\[0\] start is not a finite number/);
  assert.throws(() => gantt({ tasks: [[0,Infinity,"a"]] }), /task\[0\] end is not a finite number/);
});

// 9 — end before start is a hard, named error (an inverted task, never silently swapped).
check('end<start throws', () => {
  assert.throws(() => gantt({ tasks: [[3,1,"a"]] }), /task\[0\] end \(1\) is before start \(3\)/);
});

// 10 — a non-string label throws (a label must be text, never a coerced number/object).
check('non-string label throws', () => {
  assert.throws(() => gantt({ tasks: [[0,1,5]] }), /task\[0\] label must be a string/);
  assert.throws(() => gantt({ tasks: [[0,1,{}]] }), /label must be a string/);
});

// 11 — unknown palette throws (never a silent fallback).
check('unknown palette throws', () => {
  assert.throws(() => gantt({ tasks: [[0,1,"a"]], palette: "nope" }), /unknown palette/);
});

// 12 — mutation bite (non-vacuity): a wrong-color SVG is rejected.
check('mutation bite: a wrong-color SVG is rejected', () => {
  const wrong = TWO.replace('fill="#c25b3a"', 'fill="#4a8a52"');
  const got = gantt({ tasks: [[0,3,"design"],[2,5,"build"]] });
  assert.strictEqual(got, TWO);
  assert.notStrictEqual(got, wrong);
});

console.log('\n' + passed + '/' + n + ' passed');
if (passed !== n) process.exit(1);
Take the whole folder → MIT Node / browser, no dependencies