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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
// ---- 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"><b>&"'</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('<b>'), '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('<script>alert(1)</script>'), '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);