Skill Tracer
Skills › Archify › archify › parse.mjs

parse.mjs

300 lines · archify/explainer/parse.mjs at a406594

1import { createHash } from 'node:crypto';
2import { compileExplainerDiagram } from './diagrams.mjs';
3
4const TYPES = new Set(['architecture','workflow','sequence','dataflow','lifecycle','erd','class','tree','timeline','waterfall']);
5const ID = /^[A-Za-z][A-Za-z0-9_-]*$/;
6const stable = (kind, value) => `${kind}-${createHash('sha256').update(value).digest('hex').slice(0, 12)}`;
7const pointerKey = value => String(value).replaceAll('~','~0').replaceAll('/','~1');
8const csv = value => value ? value.split(',').map(part => part.trim()).filter(Boolean) : [];
9const rowLabel = value => {
10 const match = value.match(/^(.*?)\s+\{#([^}\s]+)\}$/u);
11 return match ? {label:match[1].trim(),id:match[2]} : {label:value};
12};
14/** Restricted author syntax; canonical JSON uses the same model and source locations.
15 * Failures return document:null and repairable diagnostics; no partial document is delivered.
16 * Source-map start/end values are offsets in the original JS string, not byte offsets.
17 */
18export function parseExplainer(sourceText, { inputPath, output } = {}) {
19 if (typeof sourceText !== 'string') throw new TypeError('Explainer input must be a string');
20 // The CLI owns the destination directory. Authored meta.output stays portable.
21 if (typeof output === 'string') output = output.split(/[\\/]/u).at(-1);
22 const sourceMap = Object.create(null);
23 const diagnostics = [];
24 const format = sourceText.trimStart().startsWith('{') || sourceText.trimStart().startsWith('[') ? 'json' : 'markdown';
25 const starts = [0];
26 for (let i = 0; i < sourceText.length; i++) if (sourceText[i] === '\n') starts.push(i + 1);
27 const lines = sourceText.split('\n').map(line => line.replace(/\r$/, ''));
28 function locate(start, end = start) {
29 let lo = 0, hi = starts.length;
30 while (lo + 1 < hi) { const mid = (lo + hi) >> 1; if (starts[mid] <= start) lo = mid; else hi = mid; }
31 return {line:lo + 1,column:start - starts[lo] + 1,start,end};
32 }
33 function map(pointer, value, descriptor) {
34 sourceMap[pointer] = {...descriptor};
35 if (Array.isArray(value)) value.forEach((entry,index) => map(`${pointer}/${index}`,entry,descriptor));
36 else if (value && typeof value === 'object') Object.entries(value).forEach(([key,entry]) => map(`${pointer}/${pointerKey(key)}`,entry,descriptor));
37 }
38 function fail(line, component, reason, example, path = '/') {
39 diagnostics.push({code:`explainer/parse-${component}`,severity:'error',message:reason,
40 subject:{line,column:1,component,path,...(inputPath ? {input:inputPath} : {})},
41 evidence:{reason,example},supportedFixes:[`Use the documented syntax, for example: ${example}`]});
42 }
43 function unsupported(text, line, component) {
44 if (/^\s*(?:>{1,}|#{3,}\s|(?:---|\*\*\*|___)\s*$|```|~~~|[-*+]\s+\[[ xX]\])/u.test(text)) fail(line,component,'This Markdown block is outside the restricted author grammar.','## What happens?\nA short paragraph.');
45 }
46 let document;
47 if (format === 'json') {
48 try {
49 document = JSON.parse(sourceText);
50 // Scan the already parsed JSON to preserve exact nested value locations and detect
51 // duplicate keys, which JSON.parse silently overwrites. No eval or JSON rewriting.
52 let pos = 0;
53 const skip = () => { while (/\s/u.test(sourceText[pos] || '') && pos < sourceText.length) pos++; };
54 const stringEnd = () => { const start = pos++; while (pos < sourceText.length) { const c = sourceText[pos++]; if (c === '\\') pos++; else if (c === '"') break; } return JSON.parse(sourceText.slice(start,pos)); };
55 function scan(pointer) {
56 skip(); const start = pos;
57 if (sourceText[pos] === '{') {
58 pos++; skip(); const keys = new Set();
59 while (sourceText[pos] !== '}') {
60 skip(); const keyStart = pos; const key = stringEnd();
61 if (keys.has(key)) fail(locate(keyStart).line,'json','Duplicate JSON keys would discard authored content.','Use each object key once.',`${pointer}/${pointerKey(key)}`);
62 keys.add(key); skip(); pos++; scan(`${pointer}/${pointerKey(key)}`); skip(); if (sourceText[pos] === ',') pos++; else break;
63 }
64 pos++;
65 } else if (sourceText[pos] === '[') {
66 pos++; skip(); let index = 0;
67 while (sourceText[pos] !== ']') { scan(`${pointer}/${index++}`); skip(); if (sourceText[pos] === ',') pos++; else break; }
68 pos++;
69 } else if (sourceText[pos] === '"') stringEnd();
70 else { while (pos < sourceText.length && !/[\s,\]}]/u.test(sourceText[pos])) pos++; }
71 sourceMap[pointer] = locate(start,pos);
72 }
73 scan('');
74 if (document && !Array.isArray(document) && typeof document === 'object') {
75 if (document.meta && typeof document.meta === 'object' && output) document.meta.output = output;
76 }
77 } catch (error) {
78 const match = String(error.message).match(/position (\d+)/u);
79 const lineMatch = String(error.message).match(/line (\d+)/u);
80 fail(match ? locate(Number(match[1])).line : Number(lineMatch?.[1] || 1),'json','Input is not valid JSON.','{"schema_version":1,"diagram_type":"explainer",...}');
81 }
82 } else {
83 document = {schema_version:1,diagram_type:'explainer',meta:{},answer:{summary:''},panels:[],sources:[]};
84 let panel = null, panelIndex = -1, titleSeen = false, answerSeen = false, reasonsAllowed = false, cursor = 0;
85 const descriptor = (line, endLine = line + 1) => locate(starts[line] ?? sourceText.length, starts[endLine] ?? sourceText.length);
86 const closePanel = end => { if (panelIndex >= 0) sourceMap[`/panels/${panelIndex}`].end = end; };
87 function heading(line, prefix) {
88 const match = line.slice(prefix.length).trim().match(/^(.*?)(?:\s+\{(?:#([^}\s]+))?(?:\s*span=(\d+))?\})?$/u);
89 return {title:match[1].trim(),id:match[2],...(match[3]?{span:Number(match[3])}:{})};
90 }
91 function addBlock(block, at, until, rows = []) {
92 if (!panel) { fail(at + 1,block.type,'Blocks must follow a question panel heading.','## What should I know?'); return; }
93 // A block named after its own panel points at the same place; keep both reachable instead of failing.
94 // Diagrams keep their ID because node and route links name it.
95 if (block.id && block.id === panel.id && block.type !== 'diagram') block.id = `${panel.id}-${block.type}`;
96 block.id ||= stable(block.type,`${panel.id}\0${JSON.stringify(block)}`);
97 if (block.type === 'comparison') for (const row of block.rows) row.id ||= stable('row', `${block.id}\0${row.label}`);
98 const pointer = `/panels/${panelIndex}/blocks/${panel.blocks.length}`;
99 panel.blocks.push(block); map(pointer,block,descriptor(at,until));
100 const property = {facts:'entries',comparison:'rows',steps:'items',list:'items'}[block.type];
101 if (property) rows.forEach((row,index) => { if (block[property]?.[index] !== undefined) map(`${pointer}/${property}/${index}`,block[property][index],descriptor(row.line,row.line+1)); });
102 }
103 function cells(raw, line) {
104 const trimmed = raw.trim();
105 let value = trimmed.startsWith('|') ? trimmed.slice(1) : trimmed;
106 if (value.endsWith('|') && !/(^|[^\\])(\\\\)*\\\|$/u.test(value)) value = value.slice(0,-1);
107 const result = []; let cell = '', code = false;
108 for (let i = 0; i < value.length; i++) {
109 const char = value[i];
110 if (char === '\\' && ['|','\\'].includes(value[i+1])) { cell += value[++i]; continue; }
111 if (char === '`') code = !code;
112 if (char === '|' && !code) { result.push(cell.trim()); cell = ''; } else cell += char;
113 }
114 result.push(cell.trim());
115 if (code) fail(line + 1,'rows','Unclosed inline code in a table row.','| label | `literal` |');
116 return result;
117 }
118 function attributes(raw,line) {
119 const attrs = Object.create(null), positional = []; let pos = 0;
120 while (pos < raw.length) {
121 while (/\s/u.test(raw[pos] || '') && pos < raw.length) pos++;
122 if (pos >= raw.length) break;
123 const token = raw.slice(pos).match(/^([^\s=]+)(=)?/u);
124 if (!token) { fail(line + 1,'directive','Malformed directive arguments.','@metric label="Latency" value=12 unit=ms'); break; }
125 const key = token[1]; pos += token[0].length;
126 if (!token[2]) { positional.push(key); continue; }
127 let value = '';
128 if (raw[pos] === '"' || raw[pos] === "'") {
129 const quote = raw[pos++]; let closed = false;
130 while (pos < raw.length) { const c = raw[pos++]; if (c === quote) { closed = true; break; } if (c === '\\' && [quote,'\\'].includes(raw[pos])) value += raw[pos++]; else value += c; }
131 if (!closed) fail(line + 1,'directive','Quoted directive value is not closed.','title="A short title"');
132 if (pos < raw.length && !/\s/u.test(raw[pos])) fail(line+1,'directive','Arguments must be separated by whitespace.','label="Latency" value=12');
133 } else { const start = pos; while (pos < raw.length && !/\s/u.test(raw[pos])) pos++; value = raw.slice(start,pos); }
134 if (Object.hasOwn(attrs,key)) fail(line+1,'directive','Duplicate arguments would discard authored content.','Use each key once.');
135 attrs[key] = value;
136 }
137 return {attrs,positional};
138 }
139 function rowWidth(rows,min,max,component) {
140 for (const row of rows) if (row.cells.length < min || row.cells.length > max) fail(row.line+1,component,`Expected ${min === max ? min : `${min}–${max}`} cells in this directive row.`,`| ${Array.from({length:min},(_,i)=>`field${i+1}`).join(' | ')} |`);
141 }
142 function boolean(value, at, component) {
143 if (value === undefined) return undefined;
144 if (!['true','false'].includes(value)) fail(at+1,component,'Boolean arguments require true or false.','multiple=true');
145 return value === 'true';
146 }
147 function number(value, at, component) {
148 if (value === undefined) return undefined;
149 if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/iu.test(value) || !Number.isFinite(Number(value))) fail(at+1,component,'Numeric arguments require a finite number.','value=12.5');
150 return Number(value);
151 }
152 function select(attrs, keys) { return Object.fromEntries(keys.filter(key => attrs[key] !== undefined).map(key=>[key,attrs[key]])); }
153 function directive(at) {
154 const match = lines[at].trim().match(/^@([a-z][a-z-]*)(?:\s+(.*))?$/u);
155 if (!match) { fail(at+1,'directive','Malformed type directive.','@facts\n| key | value |'); cursor++; return; }
156 let type = match[1]; const {attrs:a,positional} = attributes(match[2] || '',at);
157 if (TYPES.has(type)) { positional.unshift(type); type='diagram'; }
158 cursor = at + 1; const rows=[]; let literal;
159 let literalStart;
160 if (type === 'diff' && /^```diff\s*$/u.test(lines[cursor]?.trim() || '')) {
161 cursor++; const begin=cursor; literalStart=begin; while (cursor<lines.length && lines[cursor].trim()!=='```') cursor++;
162 if (cursor===lines.length) fail(at+1,'diff','The diff fence is not closed.','@diff\n```diff\n-old\n+new\n```');
163 const end=starts[cursor] ?? sourceText.length;
164 literal=sourceText.slice(starts[begin] ?? sourceText.length,end).replace(/\r?\n$/u,''); if (cursor<lines.length) cursor++;
165 } else {
166 // A directive row never starts with an empty cell; "|| id |" is a line-number prefix copied from a file viewer.
167 while (cursor<lines.length && lines[cursor].trim() && !/^(?:#{1,2}\s|@)/u.test(lines[cursor].trim())) { rows.push({cells:cells(lines[cursor].replace(/^(\s*)\|\|(?=\s)/u,'$1|'),cursor),line:cursor}); cursor++; }
168 // A callout with no rows takes the plain paragraph that follows it as its text.
169 if (type==='callout' && !rows.length && a.text===undefined) {
170 let next=cursor; while (next<lines.length && !lines[next].trim()) next++;
171 const paragraph=[]; while (next<lines.length && lines[next].trim() && !/^(?:#{1,2}\s|@|\||[-*+]\s|\d+\.\s|```|~~~)/u.test(lines[next].trim())) { paragraph.push({cells:[lines[next].trim()],line:next}); next++; }
172 if (paragraph.length) { rows.push(...paragraph); cursor=next; }
173 }
174 }
175 const allowed = {
176 source:['title','url','path','lines','revision'],meta:['locale','quality_profile','style','repo_url','repo_revision','repo_provider','repo_link_mode'],answer:['scope'],
177 prose:['id','text'],list:['id','ordered'],callout:['id','level','title','text'],facts:['id'],metric:['id','label','value','limit','unit','note','typical'],steps:['id'],diagram:['id','source','spec','title','changes'],code:['id','source','language'],diff:['id','caption'],image:['id','path','alt','caption'],comparison:['id','columns','label'],chart:['id','title','type','data','x','y','unit']
178 }[type];
179 if (!allowed) { fail(at+1,'directive',`Unsupported component @${type}.`,'@facts\n| key | value |'); return; }
180 for (const key of Object.keys(a)) if (!allowed.includes(key)) fail(at+1,type,`Unsupported argument ${key}.`,`@${type} ${allowed.map(key=>`${key}=...`).join(' ')}`);
181 if (!['source','diagram'].includes(type) && positional.length) fail(at+1,type,'This directive accepts key=value arguments only.',`@${type} id=stable-id`);
182 if (['meta','answer'].includes(type) && rows.length) fail(rows[0].line+1,type,'This directive accepts header arguments only.','@meta locale=en quality_profile=showcase');
183 if (type==='source') {
184 if (positional.length!==1) fail(at+1,type,'Source declarations require an ID.','@source docs title="Documentation" url=https://example.com/docs');
185 const source={id:positional[0],...select(a,['title','url','path','revision'])};
186 if (a.lines!==undefined) { const range=a.lines.match(/^(\d+):(\d+)$/u); if (!range) fail(at+1,type,'Repository lines require start:end.','lines=10:20'); source.lines=range?[Number(range[1]),Number(range[2])]:[]; }
187 if(rows.length) fail(at+1,type,'Source declarations do not accept rows.','@source demo title="Timing model"');
188 const pointer=`/sources/${document.sources.length}`;document.sources.push(source);map(pointer,source,descriptor(at,cursor));return;
189 }
190 if (type==='meta') { Object.assign(document.meta,select(a,['locale','quality_profile','style'])); if(a.repo_url || a.repo_revision) document.meta.repository={url:a.repo_url,revision:a.repo_revision,...(a.repo_provider?{provider:a.repo_provider}:{}),...(a.repo_link_mode?{link_mode:a.repo_link_mode}:{})}; for(const key of ['locale','quality_profile','style','repository'])if(document.meta[key]!==undefined)map(`/meta/${key}`,document.meta[key],descriptor(at,cursor));return; }
191 if (type==='answer') { if(a.scope!==undefined){document.answer.scope=a.scope;sourceMap['/answer/scope']=descriptor(at,cursor);}return; }
192 let block={type,...(a.id?{id:a.id}:{})};
193 switch(type) {
194 case 'prose': rowWidth(rows,1,1,type);block.text=a.text??rows.map(r=>r.cells[0]).join('\n');break;
195 case 'list': rowWidth(rows,1,1,type);block.items=rows.map(r=>r.cells[0]);if(a.ordered!==undefined)block.ordered=boolean(a.ordered,at,type);break;
196 case 'callout': rowWidth(rows,1,1,type);Object.assign(block,{level:a.level||'note',...select(a,['title']),text:a.text??rows.map(r=>r.cells[0]).join('\n')});break;
197 case 'facts':rowWidth(rows,2,2,type);block.entries=rows.map(r=>({key:r.cells[0],value:r.cells[1]}));break;
198 case 'metric':Object.assign(block,select(a,['label','unit','note']));for(const key of ['value','limit'])if(a[key]!==undefined)block[key]=number(a[key],at,type);if(a.typical!==undefined)block.typical=boolean(a.typical,at,type);break;
199 case 'steps':rowWidth(rows,2,7,type);block.items=rows.map(r=>{let [id,text,detail,diagram,node,route,edges]=r.cells;if(node?.includes('~')&&!route){route=node;node='';}const target=diagram?{diagram,...(node?{node}:{}),...(route?{route:route.split('~')}:{}),...(edges?{edges:csv(edges)}:{})}:undefined;if(!diagram&&(node||route||edges))fail(r.line+1,type,'A step node, route or edge reference needs its diagram ID.','| step | Read cache | | cache-flow | redis | | |');return{id,text,...(detail?{detail}:{}),...(target?{target}:{})};});break;
200 case 'diagram': {
201 if(positional.length!==1)fail(at+1,type,'A diagram requires exactly one native type.','@diagram sequence source=cache.sequence.json');
202 block.diagramType=positional[0];
203 if(a.source!==undefined)block.source=a.source;
204 if(a.spec!==undefined) { try{block.spec=JSON.parse(a.spec);}catch{fail(at+1,type,'Inline diagram spec must be valid JSON.','@diagram sequence spec=\'{"schema_version":1,...}\'');} }
205 if(rows.length&&(a.spec!==undefined||a.source!==undefined))fail(at+1,type,'Choose shorthand rows, spec or source, not several.','@diagram sequence source=cache.sequence.json');
206 // A "source:<id>" cell ties that row's node to a declared source; the reader shows it on hover.
207 for(const r of rows){const index=r.cells.findIndex(cell=>/^source:[A-Za-z][\w-]*$/u.test(cell));if(index<0)continue;const node=r.cells[0].replace(/^(?:actor|entity)\s+/u,'').trim();if(index>0&&!r.cells[0].includes('>'))(block.nodeSources||={})[node]=r.cells[index].slice(7);r.cells=r.cells.filter((_,i)=>i!==index);}
208 if(rows.length&&a.spec===undefined&&a.source===undefined)try{block.spec=compileExplainerDiagram(block.diagramType,rows.map(r=>({cells:r.cells,line:r.line+1})),{id:a.id||stable('diagram',`${panel?.id}\0${JSON.stringify(rows.map(r=>r.cells))}`),title:a.title||panel?.title||document.meta.title,line:at+1,quality:document.meta.quality_profile});}catch(error){const found=error.archifyDiagnostics||error.diagnostics;if(found?.length)diagnostics.push(...found);else fail(at+1,type,'The diagram shorthand could not compile.','@diagram sequence source=cache.sequence.json');}
209 if(a.changes!==undefined)block.changes=csv(a.changes).map(value=>{const [target,change]=value.split(':');return{target,change};});break;
210 }
211 case 'code':Object.assign(block,select(a,['source','language']));break;
212 case 'diff':block.text=literal??rows.map(r=>r.cells.join(' | ')).join('\n');Object.assign(block,select(a,['caption']));break;
213 case 'image':Object.assign(block,select(a,['path','alt','caption']));break;
214 case 'chart':{if(!a.data||!a.x||!a.y)fail(at+1,type,'A data chart names its file, label column and value columns.','@chart type=bar data=latency.csv x=day y=p50,p99');if(a.type!==undefined&&!['bar','line','pie'].includes(a.type))fail(at+1,type,'A data chart is a bar, line or pie chart.','@chart type=line data=latency.csv x=day y=p99');Object.assign(block,select(a,['title']),{data:{path:a.data,kind:a.type||'bar',x:a.x,y:csv(a.y),...(a.unit?{unit:a.unit}:{})}});break;}
215 case 'comparison':rowWidth(rows,2,500,type);if(a.label)block.labelColumn=a.label;block.columns=csv(a.columns);block.rows=rows.map(r=>({...rowLabel(r.cells[0]),cells:r.cells.slice(1).map(assessment)}));break;
216 }
217 if (['metric','code','image','chart'].includes(type)&&rows.length)fail(at+1,type,'This component accepts header arguments only.',`@${type} ${allowed.filter(k=>k!=='id').map(k=>`${k}=...`).join(' ')}`);
218 if(type!=='diff')for(const row of rows)for(const text of row.cells)unsupported(text,row.line+1,type);
219 addBlock(block,at,cursor,rows);
220 if(type==='diff'&&panel) {
221 const pointer=`/panels/${panelIndex}/blocks/${panel.blocks.length-1}/text`;
222 sourceMap[pointer]=literalStart!==undefined?locate(starts[literalStart]??sourceText.length,(starts[literalStart]??sourceText.length)+literal.length):descriptor(rows[0]?.line??at,cursor);
223 }
224 }
225 function assessment(text) {
226 // Citations may follow the assessment; they stay with the cell text.
227 const match = text.match(/^(.*?)\s*\{(good|caution|bad)(?::([^}]+))?\}((?:\s*\[[^\]]*\]\([^)\s]*\))*)\s*$/u);
228 // A cell holding only a badge ({bad:Drop}) shows the badge; its label doubles as the cell text.
229 return match?{text:`${match[1].trim()}${match[4].trim()}`||match[3]?.trim()||'',assessment:match[2],...(match[3]?{verdict:match[3].trim()}:{})}:{text};
230 }
231 while(cursor<lines.length) {
232 const at=cursor, trimmed=lines[at].trim();
233 if(!trimmed){cursor++;continue;}
234 if(trimmed.startsWith('# ')) {
235 if(titleSeen||panel)fail(at+1,'heading','Use one document title before all panels.','# What should we choose? {#choice}');
236 const h=heading(trimmed,'# ');document.meta.title=h.title;document.meta.id=h.id||stable('explainer',h.title);titleSeen=true;map('/meta',document.meta,descriptor(at));cursor++;continue;
237 }
238 if(trimmed.startsWith('## ')) {
239 closePanel(starts[at]);const h=heading(trimmed,'## ');panel={id:h.id||stable('panel',h.title),title:h.title,...(h.span!==undefined?{span:h.span}:{}),blocks:[]};panelIndex=document.panels.length;document.panels.push(panel);map(`/panels/${panelIndex}`,panel,descriptor(at,lines.length));cursor++;continue;
240 }
241 if(trimmed.startsWith('@')){reasonsAllowed=false;directive(at);continue;}
242 const fence=trimmed.match(/^(```+|~~~+)\s*([A-Za-z0-9_+.#-]*)\s*(.*)$/u);
243 if(fence) {
244 reasonsAllowed=false;
245 const {attrs:fa,positional:fp}=attributes(fence[3]||'',at);
246 if(fp.length||Object.keys(fa).some(key=>key!=='title'))fail(at+1,'snippet','A code fence accepts a language and an optional title only.','```bash title="Run the tests"');
247 cursor=at+1;const begin=cursor;while(cursor<lines.length&&lines[cursor].trim()!==fence[1])cursor++;
248 if(cursor===lines.length)fail(at+1,'snippet','The code fence is not closed.','```bash\nnpm test\n```');
249 const from=starts[begin]??sourceText.length,literal=sourceText.slice(from,starts[cursor]??sourceText.length).replace(/\r?\n$/u,'');
250 if(cursor<lines.length)cursor++;
251 if(!literal.trim()){fail(at+1,'snippet','A code fence needs content.','```bash\nnpm test\n```');continue;}
252 const language=fence[2];
253 addBlock(language==='diff'?{type:'diff',text:literal,...(fa.title?{caption:fa.title}:{})}:language==='mermaid'?{type:'chart',...(fa.title?{title:fa.title}:{}),text:literal}:{type:'snippet',...(language?{language}:{}),...(fa.title?{title:fa.title}:{}),text:literal},at,cursor);
254 if(panel)sourceMap[`/panels/${panelIndex}/blocks/${panel.blocks.length-1}/text`]=locate(from,from+literal.length);
255 continue;
256 }
257 if(trimmed.startsWith('|')) {
258 const rows=[];while(cursor<lines.length&&lines[cursor].trim().startsWith('|')){rows.push({cells:cells(lines[cursor],cursor),line:cursor});cursor++;}
259 if(rows.length<3||!rows[1].cells.every(cell=>/^:?-{3,}:?$/u.test(cell)))fail(at+1,'comparison','A Markdown table needs a header, separator and data rows.','| Criterion | A | B |\n| --- | --- | --- |\n| Cost | Low {good} | High {bad} |');
260 const columns=rows[0]?.cells.slice(1)||[];const data=rows.slice(2);for(const r of data)if(r.cells.length!==columns.length+1)fail(r.line+1,'comparison','All table rows must match the header width.','| Cost | Low {good} | High {bad} |');
261 addBlock({type:'comparison',...(rows[0]?.cells[0]?{labelColumn:rows[0].cells[0]}:{}),columns,rows:data.map(r=>({...rowLabel(r.cells[0]),cells:r.cells.slice(1).map(assessment)}))},at,cursor,data);continue;
262 }
263 const list=trimmed.match(/^(?:([-*+])|(\d+)\.)\s+(.+)$/u);
264 if(list) {
265 const rows=[];const ordered=Boolean(list[2]);while(cursor<lines.length){const found=lines[cursor].trim().match(/^(?:([-*+])|(\d+)\.)\s+(.+)$/u);if(!found||Boolean(found[2])!==ordered)break;if(/^\s{2,}/u.test(lines[cursor]))fail(cursor+1,'list','Nested lists are unsupported.','- One flat item');unsupported(found[3],cursor+1,'list');rows.push({cells:[found[3]],line:cursor});cursor++;}
266 if(!panel){if(!reasonsAllowed||ordered||document.answer.reasons)fail(at+1,'answer','Use one flat unordered reason list after the conclusion.','A conclusion.\n\n- First reason\n- Second reason');document.answer.reasons=rows.map(r=>r.cells[0]);map('/answer/reasons',document.answer.reasons,descriptor(at,cursor));rows.forEach((r,i)=>map(`/answer/reasons/${i}`,r.cells[0],descriptor(r.line)));}else{const task=/^\[[ xX]\]\s+/u,tasks=rows.filter(r=>task.test(r.cells[0])).length;if(tasks&&(ordered||tasks<rows.length))fail(at+1,'list','Write every checklist item as "- [ ] item" in one unordered list.','- [ ] Check the deposit terms\n- [ ] Photograph the meter readings');addBlock({type:'list',ordered,...(tasks?{checklist:true}:{}),items:rows.map(r=>tasks?r.cells[0].replace(task,''):r.cells[0])},at,cursor,rows);}continue;
267 }
268 const chunks=[];while(cursor<lines.length&&lines[cursor].trim()&&!/^(?:#{1,2}\s|@|\||[-*+]\s|\d+\.\s|```|~~~)/u.test(lines[cursor].trim())){unsupported(lines[cursor],cursor+1,'prose');chunks.push(lines[cursor].trim());cursor++;}
269 if(cursor===at){unsupported(trimmed,at+1,'prose');cursor++;continue;}
270 const text=chunks.join('\n');
271 if(!panel) {
272 if(!titleSeen)fail(at+1,'answer','The document title must precede the short answer.','# A title\n\nA direct answer.');
273 if(answerSeen)fail(at+1,'answer','The short answer is one paragraph before the panels.','A direct answer in one paragraph.\n\n## Why?');
274 document.answer.summary=text;answerSeen=true;reasonsAllowed=true;map('/answer',document.answer,descriptor(at,cursor));
275 } else addBlock({type:'prose',text},at,cursor);
276 }
277 closePanel(sourceText.length);
278 if(!titleSeen)fail(1,'title','A document requires one # title.','# What should we choose?');
279 if(!answerSeen)fail(1,'answer','A short answer is required before question panels.','# A title\n\nA direct answer.\n\n## Why?');
280 if(!document.panels.length)fail(1,'panel','At least one question panel is required.','## Why does this happen?');
281 if(output)document.meta.output=output;
282 }
283 const identities=new Map();
284 function identity(value,pointer,component) {
285 if(typeof value!=='string')return;
286 const line=sourceMap[pointer]?.line||sourceMap[pointer.replace(/\/id$/u,'')]?.line||1;
287 if(!ID.test(value)||value.length>100)fail(line,component,'IDs must begin with an ASCII letter and contain letters, numbers, hyphens or underscores.','id=cache-flow',pointer);
288 const previous=identities.get(value);
289 if(previous)fail(line,component,`Duplicate ID "${value}": the ${previous.component} on line ${previous.line} already uses it. Panels, blocks, diagrams and steps share one ID namespace.`,component==='panel'?`## Another question? {#${value}-2}`:`omit id=, or use id=${value}-${component}`,pointer);
290 identities.set(value,{pointer,component,line});
291 }
292 if(document&&typeof document==='object'&&!Array.isArray(document)) {
293 identity(document.meta?.id,'/meta/id','document');
294 for(const [pi,p]of (Array.isArray(document.panels)?document.panels:[]).entries()) {
295 identity(p?.id,`/panels/${pi}/id`,'panel');
296 for(const [bi,b]of (Array.isArray(p?.blocks)?p.blocks:[]).entries())identity(b?.id,`/panels/${pi}/blocks/${bi}/id`,b?.type||'block');
297 }
298 }
299 return {document:diagnostics.some(d=>d.severity==='error')?null:document,sourceMap,format,sourceText,diagnostics};