/** * Deterministic Markdown export for the Stack Anatomy panel. * * Turns the already-parsed compose anatomy into a clean Markdown document so an * operator can paste a stack summary into Git, Obsidian, BookStack, a README, or * a support thread. The builder is pure and side-effect free: the same input * always yields byte-identical output. * * It only ever receives env variable names and a count, never `.env` values, so * no secret can leak into the exported text. */ export interface PortRow { host: string; container: string; proto: string; /** * True when the mapping publishes a host port (short `host:container` form or * long syntax with `published:`/`target:`), false for container-only short * syntax (a single value with no host port). */ published?: boolean; } export interface VolumeRow { host: string; container: string; } export interface AnatomyMarkdownInput { stackName: string; services: string[]; ports: Record; volumes: Record; restart: string | null; /** Selected env file path, or null when the stack declares none. */ envFile: string | null; envVarCount: number; /** Referenced `${VAR}` names with no entry in the env file. */ missingVars: string[]; networkName: string; /** Formatted Git label (typically `host/repo#branch`) when Git-linked, else null (local). */ gitSource: string | null; } const code = (s: string): string => `\`${s}\``; // Escape the backslash first (so it cannot defeat the pipe escaping), then pipes // that would break a table row, then collapse line breaks (CRLF, LF, or a lone CR) // onto the same line. function escapeCell(value: string): string { return value .replace(/\\/g, '\\\\') .replace(/\|/g, '\\|') .replace(/\r\n?|\n/g, ' '); } function servicesSection(services: string[]): string { if (services.length === 0) return '## Services\n_none defined_'; return `## Services\n${services.map(s => `- ${code(s)}`).join('\n')}`; } function portsSection(ports: Record): string { const rows = Object.entries(ports).flatMap(([svc, list]) => list.map(r => `| ${escapeCell(svc)} | ${escapeCell(r.host)} | ${escapeCell(r.container)} | ${escapeCell(r.proto)} |`), ); if (rows.length === 0) return '## Ports\n_none_'; return ['## Ports', '| Service | Host | Container | Protocol |', '| --- | --- | --- | --- |', ...rows].join('\n'); } function volumesSection(volumes: Record): string { const rows = Object.entries(volumes).flatMap(([svc, list]) => list.map(r => `| ${escapeCell(svc)} | ${escapeCell(r.host)} | ${escapeCell(r.container)} |`), ); if (rows.length === 0) return '## Volumes\n_none_'; return ['## Volumes', '| Service | Host | Container |', '| --- | --- | --- |', ...rows].join('\n'); } function environmentSection(envFile: string | null, envVarCount: number, missingVars: string[]): string { if (!envFile) return '## Environment\n- File: _none_'; const lines = [`- File: ${code(envFile)}`, `- Variables: ${envVarCount}`]; if (missingVars.length > 0) { lines.push(`- Missing: ${missingVars.map(code).join(', ')}`); } return `## Environment\n${lines.join('\n')}`; } export function buildStackAnatomyMarkdown(input: AnatomyMarkdownInput): string { const restart = input.restart ? code(input.restart) : '_default_'; const source = input.gitSource ? `git ยท ${input.gitSource}` : 'local'; return [ `# ${input.stackName}`, servicesSection(input.services), portsSection(input.ports), volumesSection(input.volumes), `## Restart policy\n${restart}`, environmentSection(input.envFile, input.envVarCount, input.missingVars), `## Network\n${code(input.networkName)} (bridge)`, `## Source\n${source}`, ].join('\n\n'); }