inline.mjs
155 lines · archify/explainer/inline.mjs at a406594
1/** The explainer's deliberately small inline language; never an HTML escape hatch. */
2export const escapeHtml = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
4const identifier = /^[\p{L}\p{N}_-]+$/u;
5const targetIdentifier = /^[\p{L}\p{N}_.:-]+$/u;
7function referenceFor(href) {
8 if (/^https?:\/\//i.test(href)) {
9 try {
10 const url = new URL(href);
11 if (url.username || url.password || /[\u0000-\u0020\u007f]/u.test(href)) return null;
12 return {kind:'web',url:href};
13 } catch { return null; }
14 }
15 if (href.startsWith('source:') && identifier.test(href.slice(7))) return {kind:'source',id:href.slice(7)};
16 if (href.startsWith('block:') && identifier.test(href.slice(6))) return {kind:'block',id:href.slice(6)};
17 if (href.startsWith('#') && identifier.test(href.slice(1))) return {kind:'block',id:href.slice(1)};
18 if (href.startsWith('typical:') && href.slice(8).trim()) return {kind:'typical',text:href.slice(8).trim()};
19 if (href.startsWith('node:') || href.startsWith('route:')) {
20 const kind = href.startsWith('node:') ? 'node' : 'route';
21 const value = href.slice(kind.length + 1);
22 const split = value.indexOf('.');
23 if (split < 1 || !identifier.test(value.slice(0,split))) return null;
24 const diagram = value.slice(0,split), target = value.slice(split+1);
25 if (kind === 'node' && targetIdentifier.test(target)) return {kind,diagram,node:target};
26 const route = target.split('~');
27 if (kind === 'route' && route.length === 2 && route.every(id => targetIdentifier.test(id))) return {kind,diagram,from:route[0],to:route[1]};
28 }
29 return null;
30}
32/** Returns tokens and references, with offsets for validation and actionable diagnostics. */
33export function parseInline(value, options = {}) {
34 const text = String(value ?? '');
35 const references = [], diagnostics = [];
36 function problem(code, index, reason, example) {
37 const before = text.slice(0,index), lastBreak = before.lastIndexOf('\n');
38 diagnostics.push({code:`explainer/inline-${code}`,severity:'error',message:reason,
39 subject:{line:(Number.isInteger(options.line) ? options.line : 1) + (before.match(/\n/g)?.length || 0),column:index-lastBreak,
40 component:options.component || 'inline',...(options.panelId ? {panelId:options.panelId} : {}),...(options.input ? {input:options.input} : {})},
41 evidence:{reason,example},supportedFixes:[`Use the supported form: ${example}`]});
42 }
43 function segment(start, end, depth = 0) {
44 const tokens = [];
45 const literal = value => {
46 if (!value) return;
47 const previous = tokens[tokens.length-1];
48 if (previous?.type === 'text') previous.value += value;
49 else tokens.push({type:'text',value});
50 };
51 for (let i=start;i<end;) {
52 const c=text[i];
53 if (c === '\\' && i+1 < end && /[\\`*_[\]()!<>~|]/u.test(text[i+1])) { literal(text[i+1]);i+=2;continue; }
54 if (c === '`') {
55 const close=text.indexOf('`',i+1);
56 if (close === -1 || close >= end || text.slice(i+1,close).includes('\n')) {
57 problem('code',i,'Inline code must have one closing backtick on the same line.','`cache.get(key)`');literal(c);i++;continue;
58 }
59 tokens.push({type:'code',value:text.slice(i+1,close)});i=close+1;continue;
60 }
61 if (c === '<' && /^(?:<\/?[A-Za-z][^>]*>|<!--)/u.test(text.slice(i,end))) {
62 problem('html',i,'Raw HTML is not part of the explainer inline language.','**Important** or `[literal <tag>]`');
63 literal(c);i++;continue;
64 }
65 if (text.startsWith('![',i)) {
66 problem('image',i,'Inline Markdown images are unsupported. Use a contained image block.','@image path=diagram.png alt="Request path"');
67 literal('!');i++;continue;
68 }
69 if (text.startsWith('~~',i)) { problem('decoration',i,'Strikethrough is unsupported; use a diff or an explicit change block.','Changed: **new behavior**');literal('~~');i+=2;continue; }
70 if (c === '*') {
71 const marker=text.startsWith('**',i) ? '**' : '*';
72 let close=text.indexOf(marker,i+marker.length);
73 const escaped = index => {
74 let slashes=0;
75 while (index > start && text[--index] === '\\') slashes++;
76 return slashes % 2 === 1;
77 };
78 while (close > -1 && escaped(close)) close=text.indexOf(marker,close+marker.length);
79 if (close < 0 || close >= end || close === i+marker.length || depth >= 6) {
80 problem('emphasis',i,'Emphasis needs matching nonempty delimiters. Escape a literal asterisk.','**important** or \\*');literal(marker);i+=marker.length;continue;
81 }
82 tokens.push({type:marker.length===2?'strong':'em',children:segment(i+marker.length,close,depth+1)});i=close+marker.length;continue;
83 }
84 if (c === '[') {
85 const labelEnd=text.indexOf(']',i+1);
86 if (labelEnd > -1 && labelEnd < end && text[labelEnd+1] === '(') {
87 let close=labelEnd+2, nesting=1;
88 for (;close<end;close++) {
89 if (text[close] === '\\') { close++;continue; }
90 if (text[close] === '(') nesting++;
91 if (text[close] === ')' && --nesting === 0) break;
92 }
93 if (close >= end || nesting !== 0) {
94 problem('link',i,'The link destination is not closed.','[Evidence](source:cache-read)');literal(c);i++;continue;
95 }
96 const label=text.slice(i+1,labelEnd), href=text.slice(labelEnd+2,close).replace(/\\([()\\])/g,'$1');
97 const reference=referenceFor(href);
98 if ((!label.trim() && reference?.kind !== 'source') || !reference) {
99 problem('target',i,'Use a nonempty label and an HTTP(S), source, block, node, route or typical destination.','[Redis](node:read-path.redis)');
100 literal(text.slice(i,close+1));
101 } else {
102 const entry={...reference,offset:i,end:close+1};references.push(entry);
103 tokens.push({type:'link',label,href,reference:entry});
104 }
105 i=close+1;continue;
106 }
107 if (text[i+1] === '^') problem('footnote',i,'Arbitrary Markdown footnotes are unsupported. Use a source or typical-value reference.','[Claim](source:trace)');
108 }
109 literal(c);i++;
110 }
111 return tokens;
112 }
113 const tokens=segment(0,text.length);
114 return {tokens,references,diagnostics};
115}
117function inlineError(diagnostics) {
118 const error=new Error(diagnostics[0].message);
119 error.archifyDiagnostics=diagnostics;
120 error.diagnostics=diagnostics;
121 return error;
122}
124/** Safely render the same parsed language used by semantic validation. */
125export function renderInline(value, context = {}) {
126 const parsed=parseInline(value,context);
127 if (parsed.diagnostics.length) throw inlineError(parsed.diagnostics);
128 const render = tokens => tokens.map(token => {
129 if (token.type === 'text') return escapeHtml(token.value);
130 if (token.type === 'code') return `<code>${escapeHtml(token.value)}</code>`;
131 if (token.type === 'strong' || token.type === 'em') return `<${token.type}>${render(token.children)}</${token.type}>`;
132 const label=escapeHtml(token.label), ref=token.reference;
133 if (ref.kind === 'web') return `<a href="${escapeHtml(ref.url)}" target="_blank" rel="noopener noreferrer">${label}</a>`;
134 if (ref.kind === 'source') {
135 const citations = context.citations || (context.citations = new Map());
136 if (!citations.has(ref.id)) citations.set(ref.id,citations.size + 1);
137 return `${label}<sup class="cite"><a href="#source-${escapeHtml(ref.id)}">${citations.get(ref.id)}</a></sup>`;
138 }
139 if (ref.kind === 'block') return `<a href="#${escapeHtml(ref.id)}" data-block-ref="${escapeHtml(ref.id)}">${label}</a>`;
140 if (ref.kind === 'node' || ref.kind === 'route') {
141 const hash=ref.kind==='node' ? `focus=${encodeURIComponent(ref.node)}` : `route=${encodeURIComponent(ref.from)}~${encodeURIComponent(ref.to)}`;
142 return `<a class="diagram-link" href="#${escapeHtml(ref.diagram)}" data-diagram-link="${escapeHtml(ref.diagram)}" data-diagram-hash="${escapeHtml(hash)}">${label}</a>`;
143 }
144 if (ref.kind === 'typical') {
145 if (Array.isArray(context.footnotes)) {
146 const index=context.footnotes.length+1,id=`explainer-typical-${index}`;
147 context.footnotes.push({id,index,text:ref.text,label:token.label});
148 return `<span class="typical" title="${escapeHtml(ref.text)}">${label}<sup><a href="#${id}" role="doc-noteref" class="typical-ref">[${index}]</a></sup></span>`;
149 }
150 return `<span class="typical">${label}<small class="typical-note"> (${escapeHtml(ref.text)})</small></span>`;
151 }
152 return label;
153 }).join('');
154 return render(parsed.tokens);
155}