import type { ApiMapFile } from '@junixlabs/apiflow-map'; import type { Alert } from '@junixlabs/apiflow-map'; import { alertCounts, alerts as computeAlerts } from '@junixlabs/apiflow-map'; import { endpointReliability, summarize } from '@junixlabs/apiflow-map'; import type { MapDiff } from '@junixlabs/apiflow-map'; import { APP_STYLE } from './appStyle'; import { relativeAge } from './hub'; import type { SideInfo } from '../workspace/sides'; import type { EndpointHistory, MapSeries } from '../workspace/series'; import { ADD_DIALOG, ADD_SCRIPT, ADD_STYLE } from './addProject'; import { PANES_HTML, PANES_SCRIPT, PANES_SCRIPT_2, PANES_SCRIPT_3, PANES_SCRIPT_4, PANES_STYLE } from './panes'; import { BRAND_STYLE, FAVICON, MARK, STYLE, THEME_BOOT, THEME_SCRIPT, THEME_STYLE } from './theme'; export interface AppPayload { map: ApiMapFile; projectId?: string; sourcePath: string; live: boolean; kind?: string; projectName?: string; homeHref?: string; hints?: string; sides?: SideInfo[]; now?: number; series?: MapSeries | null; epHistory?: EndpointHistory | null; history?: Array<{ id: string; when?: string }>; diff?: MapDiff; } export type Section = | 'overview' | 'endpoints' | 'cover' | 'graph' | 'impact' | 'screens' | 'unresolved' | 'alerts' | 'compare'; export const SECTIONS: Array<{ id: Section; label: string }> = [ { id: 'overview', label: 'Overview' }, { id: 'endpoints', label: 'Endpoints' }, { id: 'cover', label: 'Coverage' }, { id: 'graph', label: 'Impact ring' }, { id: 'impact', label: 'Impact' }, { id: 'screens', label: 'Screens' }, { id: 'unresolved', label: 'Unresolved' }, { id: 'alerts', label: 'Alerts' }, { id: 'compare', label: 'Compare' }, ]; export function escapeHtml(value: string): string { return value.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] as string); } // cm:guard `` inside a field name or a snippet would close the tag and turn the payload into // markup. Escaping `<` is what keeps a scanned repo from injecting into the page it is rendered on. export function embedJson(value: unknown): string { return JSON.stringify(value) .replace(/ (c === '\u2028' ? '\\u2028' : '\\u2029')); } function donut(parts: Array<{ n: number; cls: string }>, total: number): string { const R = 26; const C = 2 * Math.PI * R; let offset = 0; const rings = parts .filter((p) => p.n > 0) .map((p) => { const len = total === 0 ? 0 : (p.n / total) * C; const seg = ``; offset += len; return seg; }) .join(''); return `${rings}`; } // cm:guard Stroke-only 16px glyphs using currentColor — a filled or coloured icon set would need a // second pass for the dark palette, and the rail is the one place both themes must agree. const ICONS: Record = { overview: '', endpoints: '', cover: '', graph: '', impact: '', screens: '', unresolved: '', alerts: '', compare: '', }; const icon = (id: Section) => ``; const KIND_WORD: Record = { fe: 'FE', be: 'BE', linked: 'FE+BE' }; // cm:why Names the revision each half was scanned AT, not the revision it is on now: the map is a // photograph, and a header that quietly shows today's branch makes a stale map look current. // cm:guard Prints "revision unreadable" rather than nothing when .git is unreadable — a blank // where a sha belongs reads as "same as before". function sideLine(side: SideInfo, now: number): string { const rev = side.branch === undefined && side.sha === undefined ? 'revision unreadable' : `${escapeHtml([side.branch, side.sha].filter((x) => x !== undefined).join(' · '))}`; return `
${side.kind.toUpperCase()} ${escapeHtml(side.root)} ${rev} ${escapeHtml(relativeAge(side.scannedAt, now))}
`; } function header(payload: AppPayload, name: string): string { const now = payload.now ?? 0; // cm:guard Every control here is gated on `live`: the same renderer writes the offline file that // `apiflow view` produces, and a form posting to a server that is not running is a dead button. const sides = payload.sides ?? []; const root = (kind: 'fe' | 'be') => sides.find((s) => s.kind === kind)?.root ?? ''; // cm:edge contract -> packages/cli/src/view/addProject.ts — it reads these data-* attributes to prefill the // dialog in edit mode, so a renamed attribute here silently opens an empty form. const editBtn = payload.projectId === undefined ? '' : ``; // cm:why `+ Add project` is a workspace action, so it lives at the foot of the rail on this page and // on the hub alike. In this row it read as one of the things you can do TO the project you opened. // cm:guard A side with no directory on this machine gets no scan button: pressing it answers "no // directory to scan", which reads as a broken project when the truth is the side arrives as a file. const scannable = (kind: 'fe' | 'be') => sides.some((s) => s.kind === kind && s.imported !== true); const scanBtn = (kind: 'fe' | 'be', cls: string) => scannable(kind) || sides.every((s) => s.kind !== kind) ? `` : ``; const scanBtns = payload.live ? `
${payload.projectId === undefined ? '' : `${scanBtn('fe', 'primary')} ${scanBtn('be', '')}`} ${editBtn}
` : ''; return `

${escapeHtml(name)}

${payload.kind === undefined ? '' : `${escapeHtml(KIND_WORD[payload.kind] ?? payload.kind)}`} ${payload.live ? 'live' : 'static'}
${sides.length === 0 ? `
ROOT${escapeHtml(payload.map.metadata.root)}
` : sides.map((side) => sideLine(side, now)).join('\n')}
${escapeHtml(payload.map.metadata.generator)}
${scanBtns}
`; } function rail(payload: AppPayload, counts: { alerts: number; high: number }, unresolved: number): string { const link = (id: Section, label: string, n?: number, tone = '') => `${icon(id)}${label}${n === undefined ? '' : `${n}`}`; // cm:why The brand is the way back to the workspace, and it is a plain heading when there is nowhere // to go: `apiflow view` writes a file with no hub beside it, and a dead link there is worse than none. const brand = `${MARK}

apiflow

`; // cm:guard Same element either way, so the two pages start at the same height: a link only on one // of them shifted the whole rail by 16px, which is exactly the kind of drift nobody can name. return `
${payload.homeHref === undefined ? `${brand}` : `${brand}`}
${payload.live ? '' : ''}

${escapeHtml(payload.sourcePath)}

`; } function overview(payload: AppPayload, list: Alert[]): string { const map = payload.map; const sum = summarize(map); const counts = alertCounts(list); const total = sum.both + sum.uncalled + sum.feOnly + sum.unpaired; const pct = (n: number) => (total === 0 ? '0%' : `${((n / total) * 100).toFixed(2)}%`); const calls = sum.confidence; const callTotal = calls.exact + calls.inferred + calls.guess; return `

Reconciliation — ${sum.endpoints} endpoints

${sum.both} seen from both sides
${sum.uncalled} declared, no screen calls it
${sum.feOnly} FE calls it, API does not declare it
${sum.unpaired} not reconciled yet

Confidence — ${callTotal} calls

${donut([ { n: calls.exact, cls: 'c-exact' }, { n: calls.inferred, cls: 'c-inferred' }, { n: calls.guess, cls: 'c-guess' }, ], callTotal)}
exact ${calls.exact}
inferred ${calls.inferred}
guess ${calls.guess}

Every number here is a candidate, not a verdict. The reconciliation bar does not include the ${sum.unresolved} Unresolved entries — those are the denominator of confidence, not endpoints. “no auth” means no gate was found in the code.

`; } export function renderApp(payload: AppPayload): string { const list = computeAlerts(payload.map); const counts = alertCounts(list); const reliability = [...endpointReliability(payload.map)].map(([id, r]) => [id, r.exact, r.inferred, r.guess] as const); // cm:guard Titled by the PROJECT when one backs the page, not by the map: the hub rail calls it // `webapp`, and landing on a page headed `webapp-ui+webapp-api` reads as a different thing. // cm:guard The map's own name still shows up in the generator line under the roots. const name = payload.projectName ?? payload.map.metadata.name; return ` apiflow — ${escapeHtml(name)} ${THEME_BOOT}
${rail(payload, { alerts: counts.total, high: counts.high }, payload.map.unresolved.length)}
${header(payload, name)} ${overview(payload, list)}

${payload.live ? ADD_DIALOG : ''}
${PANES_HTML}
`; }