Eleventy • record-to-route workbench — Map one JSON array through one native template into exactly one output per record, preserving stable IDs and auditing…
HTML for this example
Select the code and use your copy shortcut. Changes in this preview are temporary.
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Eleventy • record-to-route workbench</title><style>*{box-sizing:border-box}body{margin:0;background:var(--bg,#f3f7fa);color:var(--ink,#203342);font:16px/1.55 system-ui,Segoe UI,sans-serif}main{max-width:1100px;margin:auto;padding:28px 30px 50px}h1{font-size:clamp(27px,3.5vw,40px);line-height:1.18;letter-spacing:-.035em;margin:8px 0 12px}h2{font-size:21px;line-height:1.35;margin:0 0 16px}h3{font-size:18px}.eyebrow{color:var(--accent,#176783);font-size:12px;letter-spacing:.12em;font-weight:800;text-transform:uppercase}.intro,.muted,small{color:#516272}.intro{margin:0 0 24px}.panel{padding:24px;border:1px solid var(--line,#d6e0e7);border-radius:12px;background:white;margin:18px 0}.grid{display:grid;grid-template-columns:1fr 1fr;gap:22px}.grid>*{min-width:0}.controls{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin:16px 0}button,input,textarea,select{font:inherit}button{border:1px solid var(--accent,#176783);background:var(--accent,#176783);color:white;padding:10px 16px;min-height:44px;border-radius:7px;font-weight:700;cursor:pointer}button.secondary{background:white;color:var(--accent,#176783)}button[aria-pressed=true]{background:var(--tint,#e3f2f8);color:var(--ink,#203342);box-shadow:inset 0 0 0 2px var(--accent,#176783)}button:hover{filter:brightness(.96)}:focus-visible{outline:3px solid #d56b00;outline-offset:3px}label{display:block;margin:12px 0 5px;font-size:14px;font-weight:650}input:not([type=checkbox]),select,textarea{width:100%;padding:10px 12px;min-height:44px;border:1px solid #94a5b2;background:white;color:inherit;border-radius:7px}textarea{resize:vertical;min-height:120px}input[type=checkbox]{width:20px;height:20px;margin-right:9px}label.inline{display:flex;align-items:center;font-weight:500;gap:7px}.result{padding:24px;background:var(--tint,#e3f2f8);border:1px solid var(--line,#c8dce7);border-radius:12px}.big{font-size:clamp(30px,5vw,48px);font-weight:780;line-height:1.25;letter-spacing:-.035em;margin:10px 0}.trace{font:15px/1.7 ui-monospace,Consolas,monospace;white-space:pre-wrap;overflow-wrap:anywhere}.error{color:#a12820;font-weight:650;min-height:24px}.table-wrap{overflow-x:auto}table{border-collapse:collapse;width:100%;font-size:15px}th,td{text-align:left;border-bottom:1px solid var(--line,#d6e0e7);padding:12px 9px;vertical-align:top}th{font-size:12px;text-transform:uppercase;letter-spacing:.03em;color:#516272}code,pre,.code{font:14px/1.65 ui-monospace,Consolas,monospace}pre{white-space:pre-wrap;overflow-wrap:anywhere;background:var(--tint,#eef3f7);padding:16px;border-radius:6px}details{border-top:1px solid var(--line,#d6e0e7);margin:24px 0;padding-top:16px}summary{cursor:pointer;font-weight:700;min-height:44px}footer{border-top:1px solid var(--line,#d6e0e7);margin-top:26px;padding-top:16px;font-size:13px;color:#516272}.bar{height:16px;background:#dce4ea;border-radius:20px;overflow:hidden;margin:12px 0}.bar span{display:block;height:100%;background:var(--accent,#176783)}[hidden]{display:none!important}@media(max-width:720px){main{padding:20px}.grid{grid-template-columns:1fr;gap:12px}.panel,.result{padding:20px}}@media(max-width:400px){main{padding:16px}.panel,.result{padding:16px}button{padding:10px 13px}}
body{--accent:#7b3bd0;--bg:#f5f3fa;--tint:#f1eafa;--ink:#292338;--line:#ded8ea}.manifest{border-top:5px solid #7b3bd0}.route-row{display:grid;grid-template-columns:1.7fr 1fr;gap:6px;padding:13px 0;border-bottom:1px solid #ded8ea}.route-row small{grid-column:2}.route-row code{overflow-wrap:anywhere}.paper{padding:24px 20px;border:1px solid #ddd5e4;box-shadow:4px 4px 0 #ece6f3;min-height:130px}.paper h3{font:26px Georgia,serif}#comparison{white-space:pre-wrap}@media(max-width:720px){.route-row{grid-template-columns:1fr}.route-row small{grid-column:1}}</style></head><body><main>
<header><span class="eyebrow">Eleventy companion / one record, one route</span><h1>Give every record a stable route.</h1><p class="intro">Validate your data and inspect the expected page set before a native build.</p></header>
<section class="panel manifest"><span class="eyebrow">Expected output • local projection</span><h2 id="summary">3 records → 3 expected pages</h2><div id="manifest"></div><p id="error" class="error" role="alert"></p></section>
<div class="grid"><section class="panel"><h2>Source records</h2><label for="data">_data/tools.json</label><textarea id="data" class="code" rows="9">[{"id":"note-a","label":"Note A"},
{"id":"note-b","label":"Note B"},
{"id":"note-c","label":"Note C"}]</textarea><div class="controls"><button type="button" id="project">Validate & project</button><button type="button" id="reorder" class="secondary">Reverse order</button><button type="button" id="reset" class="secondary">Original data</button></div><p class="muted">IDs: lowercase letters, digits, and hyphens; unique and nonempty. Labels: ordinary text strings.</p></section><section class="panel"><h2>Selected expected page</h2><div id="page-tabs" class="controls"></div><article class="paper"><h3 id="page-title">Note A</h3><p id="page-id">Record ID: note-a</p></article><label for="page-source">Escaped HTML source</label><textarea id="page-source" class="code" readonly rows="5"></textarea></section></div>
<section class="panel"><h2>Compare a native observed manifest</h2><p>After building into a fresh isolated output, enter only fixture pages as JSON objects with <code>path</code>, <code>id</code>, and observed <code>label</code>. Empty input means no observations.</p><label for="observed">Actual observations — blank until you provide them</label><textarea id="observed" class="code" placeholder='[{"path":"tools/note-a/index.html","id":"note-a","label":"Note A"}]'></textarea><div class="controls"><button type="button" id="compare">Compare observations</button></div><p id="comparison" aria-live="polite">Native observations pending.</p></section>
<details><summary>Native template to use in your own Eleventy project</summary><label for="template">tool-pages.njk</label><textarea id="template" class="code" readonly rows="13">---
pagination:
data: tools
size: 1
alias: tool
permalink: "tools/{{ tool.id }}/index.html"
---
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>{{ tool.label | escape }}</title></head>
<body><h1>{{ tool.label | escape }}</h1>
<p>Record ID: {{ tool.id | escape }}</p></body></html></textarea><button type="button" id="select-template" class="secondary">Select text</button><p class="muted">Copy selected text manually. Verify your Nunjucks environment and installed version; 3.1.6 is the manuscript's chosen reference.</p></details>
<footer>This companion projects and compares supplied records; it does not run Eleventy, create route files, inspect your filesystem, or remove stale output. Native build/configuration/version evidence remains authoritative. Fictional data; nothing is saved between visits.</footer>
</main><script>const $=id=>document.getElementById(id);const on=(id,e,fn)=>$(id).addEventListener(e,fn);const txt=(id,v)=>$(id).textContent=v;const esc=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));const positive=x=>String(x).trim()!==''&&Number.isFinite(Number(x))&&Number(x)>0;const num=x=>Number(x.toPrecision(10)).toString();function enter(id,fn){on(id,'keydown',e=>{if(e.key==='Enter'&&!e.isComposing&&e.keyCode!==229){e.preventDefault();fn()}})}function selectText(id){$(id).focus();$(id).select()}
const original=[{id:'note-a',label:'Note A'},{id:'note-b',label:'Note B'},{id:'note-c',label:'Note C'}];let current=null;
function projectData(raw){let data;try{data=JSON.parse(raw)}catch(e){throw Error('Enter valid JSON.')}if(!Array.isArray(data))throw Error('The dataset must be an array.');const seen=new Set();return data.map((r,i)=>{if(!r||typeof r!=='object'||typeof r.id!=='string'||!/^[a-z0-9-]+$/.test(r.id))throw Error('Record '+(i+1)+': missing or unsafe ID.');if(seen.has(r.id))throw Error('Duplicate ID: '+r.id+'. No projection accepted.');if(typeof r.label!=='string')throw Error('Record '+r.id+': label must be a string.');seen.add(r.id);return {id:r.id,label:r.label,path:'tools/'+r.id+'/index.html',html:'<!doctype html>\n<html lang="en"><head><meta charset="utf-8"><title>'+esc(r.label)+'</title></head><body><h1>'+esc(r.label)+'</h1><p>Record ID: '+esc(r.id)+'</p></body></html>'}})}
function showPage(i){const r=current[i];txt('page-title',r?r.label:'No pages');txt('page-id',r?'Record ID: '+r.id:'Empty dataset → zero fixture pages.');$('page-source').value=r?r.html:''}
function invalidate(){current=null;txt('summary','Projection pending');txt('manifest','Data changed. Validate the current dataset.');txt('page-title','No current page');txt('page-id','');$('page-source').value='';$('page-tabs').innerHTML='';txt('comparison','Native comparison pending: expectations changed.');txt('error','')}
function project(){try{current=projectData($('data').value);txt('summary',current.length+' records → '+current.length+' expected pages');$('manifest').innerHTML=current.length?'<div class="route-list">'+current.map(r=>'<div class="route-row"><code>'+esc(r.path)+'</code><span>'+esc(r.label)+'</span><small>Record ID: '+esc(r.id)+'</small></div>').join('')+'</div>':'<p>Zero fixture pages. Use a fresh output for the empty native case.</p>';$('page-tabs').innerHTML='';current.forEach((r,i)=>{const b=document.createElement('button');b.type='button';b.textContent=r.id;b.className='secondary';b.addEventListener('click',()=>showPage(i));$('page-tabs').appendChild(b)});showPage(0);txt('error','');txt('comparison','Native observations pending.')}catch(e){invalidate();txt('summary','Data gate held');txt('error',e.message)}}
function compareManifest(expected,actual){if(!Array.isArray(actual))throw Error('Observed manifest must be an array.');const paths=new Set();for(const r of actual){if(!r||typeof r.path!=='string'||typeof r.id!=='string'||typeof r.label!=='string')throw Error('Each observation needs string path, id, and label.');if(paths.has(r.path))throw Error('Repeated observed path: '+r.path);paths.add(r.path)}const issues=[];for(const e of expected){const a=actual.find(a=>a.path===e.path);if(!a)issues.push('Missing '+e.path);else if(a.id!==e.id||a.label!==e.label)issues.push('Content mismatch at '+e.path)}for(const a of actual)if(!expected.some(e=>e.path===a.path))issues.push('Unexpected '+a.path);return issues.length?issues.join('\n'):'All '+expected.length+' supplied route/content observations match. Native provenance still requires review.'}
on('data','input',invalidate);on('project','click',project);on('reset','click',()=>{$('data').value=JSON.stringify(original,null,2);project()});on('reorder','click',()=>{try{const d=JSON.parse($('data').value);if(!Array.isArray(d))throw Error();$('data').value=JSON.stringify(d.reverse(),null,2);project()}catch(e){invalidate();txt('error','Enter a valid JSON array before reordering.')}});on('observed','input',()=>txt('comparison','Observations changed. Compare again.'));on('compare','click',()=>{try{if(current===null)throw Error('Validate the source records first.');if(!$('observed').value.trim())throw Error('Native observations pending: supply an actual manifest.');txt('comparison',compareManifest(current,JSON.parse($('observed').value)))}catch(e){txt('comparison',e.message)}});on('select-template','click',()=>selectText('template'));project();
</script></body></html>