// v1.14.x-html-artifact-panes: artifact writer + slug derivation.
//
// Writes Markdown and HTML artifacts to `/.boocode/artifacts/`
// as plain files. Returns `{path, url}` where:
// - path is the absolute on-disk path
// - url is a project-scoped REST URL pointing at the GET download route
// registered in routes/artifacts.ts. The route streams the file with
// Content-Disposition: attachment.
//
// Path safety: we do NOT use path_guard.ts (it realpaths and throws ENOENT
// for files that don't exist yet, which artifact creation requires).
// Instead we mirror the v1.13.18 codecontext_client.ts pattern: resolve
// the candidate path against the realpath'd projectRoot, then verify the
// result starts with projectRoot + sep (or equals projectRoot).
import { mkdir, realpath, writeFile } from 'node:fs/promises';
import { resolve, sep } from 'node:path';
import { PathScopeError } from './path_guard.js';
import type { Message } from '../types/api.js';
export interface HtmlArtifactPayload {
html_content: string;
char_count: number;
title: string | null;
}
export interface ArtifactWriteResult {
path: string;
url: string;
}
const ARTIFACT_SUBDIR = '.boocode/artifacts';
// ---- slug helpers ----
// Lowercase, replace non-alnum runs with '-', trim leading/trailing '-',
// collapse repeated '-', cap at 60 chars. Empty → 'artifact'.
function slugify(input: string): string {
const cleaned = input
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/-{2,}/g, '-')
.slice(0, 60)
.replace(/^-+|-+$/g, '');
return cleaned || 'artifact';
}
function firstHeading(md: string): string | null {
// Match the first `# ` ATX heading at the start of a line.
const m = md.match(/^[ \t]*#[ \t]+(.+?)\s*$/m);
if (!m) return null;
const text = m[1]?.trim() ?? '';
return text.length > 0 ? text : null;
}
function firstNWords(s: string, n: number): string {
const words = s.trim().split(/\s+/).filter(Boolean).slice(0, n);
return words.join(' ');
}
export function deriveMarkdownSlug(messageContent: string): string {
const heading = firstHeading(messageContent);
if (heading) return slugify(heading);
const sixWords = firstNWords(messageContent, 6);
return slugify(sixWords);
}
// Strip HTML tags for inner-text extraction. Crude but sufficient for slug
// derivation — we're not rendering, just finding readable words.
function stripTags(html: string): string {
return html
.replace(/