@sudocode-ai/cli
Version:
Git-native spec and issue management CLI for AI-assisted software development
329 lines • 12 kB
JavaScript
/**
* Markdown parser with frontmatter support
*/
import matter from "gray-matter";
import * as fs from "fs";
import { getSpec } from "./operations/specs.js";
import { getIssue } from "./operations/issues.js";
import { createFeedbackAnchor } from "./operations/feedback-anchors.js";
/**
* Parse markdown file with YAML frontmatter
* @param content - Markdown content to parse
* @param db - Optional database for validating cross-references
* @param outputDir - Optional output directory for loading config metadata
*/
export function parseMarkdown(content, db, outputDir) {
const parsed = matter(content);
// Extract cross-references from content
const references = extractCrossReferences(parsed.content, db);
return {
data: parsed.data,
content: parsed.content,
raw: content,
references,
};
}
/**
* Parse markdown file from disk
* @param filePath - Path to markdown file
* @param db - Optional database for validating cross-references
* @param outputDir - Optional output directory for loading config metadata
*/
export function parseMarkdownFile(filePath, db, outputDir) {
const content = fs.readFileSync(filePath, "utf8");
return parseMarkdown(content, db, outputDir);
}
/**
* Convert character index to line number
*/
function getLineNumber(content, charIndex) {
const beforeMatch = content.substring(0, charIndex);
return beforeMatch.split("\n").length;
}
/**
* Convert FeedbackAnchor to LocationAnchor (strips tracking fields)
*/
function feedbackAnchorToLocationAnchor(feedbackAnchor) {
return {
section_heading: feedbackAnchor.section_heading,
section_level: feedbackAnchor.section_level,
line_number: feedbackAnchor.line_number,
line_offset: feedbackAnchor.line_offset,
text_snippet: feedbackAnchor.text_snippet,
context_before: feedbackAnchor.context_before,
context_after: feedbackAnchor.context_after,
content_hash: feedbackAnchor.content_hash,
};
}
/**
* Extract cross-references from markdown content
* Supports formats:
* - [[i-x7k9]] or [[s-14sh]] - hash-based entity reference
* - [[@i-x7k9]] - entity reference with @ prefix (for clarity)
* - [[i-x7k9|Display Text]] - with custom display text
* - [[i-x7k9]]{ blocks } - with relationship type (shorthand)
* - [[i-x7k9]]{ type: blocks } - with relationship type (explicit)
* - [[i-x7k9|Display]]{ blocks } - combination of display text and type
*
* If db is provided, validates references against the database and determines entity type.
* Only returns references to entities that actually exist.
*
* If db is not provided, determines entity type from hash-based ID prefix (i- or s-).
*/
export function extractCrossReferences(content, db) {
const references = [];
// Pattern: [[optional-@][entity-id][|display-text]]optional-metadata
// Supports hash-based IDs only:
// - [[i-x7k9]] or [[s-14sh]]
// - [[i-x7k9|Display Text]]
// - [[i-x7k9]]{ blocks }
// - [[i-x7k9]]{ type: depends-on }
const refPattern = /\[\[(@)?([is]-[0-9a-z]{4,8})(?:\|([^\]]+))?\]\](?:\{\s*(?:type:\s*)?([a-z-]+)\s*\})?/gi;
let match;
while ((match = refPattern.exec(content)) !== null) {
const hasAt = match[1] === "@";
const id = match[2];
const displayText = match[3]?.trim();
const relationshipType = match[4]?.trim();
// Create location anchor for this reference
let anchor;
try {
const lineNumber = getLineNumber(content, match.index);
const feedbackAnchor = createFeedbackAnchor(content, lineNumber, match.index);
anchor = feedbackAnchorToLocationAnchor(feedbackAnchor);
}
catch (error) {
// If anchor creation fails, continue without it
anchor = undefined;
}
if (db) {
let entityType = null;
try {
const spec = getSpec(db, id);
if (spec) {
entityType = "spec";
}
}
catch (error) { }
if (!entityType) {
try {
const issue = getIssue(db, id);
if (issue) {
entityType = "issue";
}
}
catch (error) { }
}
if (entityType) {
references.push({
match: match[0],
id,
type: entityType,
index: match.index,
displayText,
relationshipType,
anchor,
});
}
}
else {
// Determine type from hash-based ID prefix
// Hash IDs always use i- for issues, s- for specs
const type = id.startsWith("i-") ? "issue" : "spec";
references.push({
match: match[0],
id,
type,
index: match.index,
displayText,
relationshipType,
anchor,
});
}
}
return references;
}
/**
* Stringify frontmatter and content back to markdown
*/
export function stringifyMarkdown(data, content) {
return matter.stringify(content, data);
}
/**
* Update frontmatter in an existing markdown file
* Preserves content unchanged
*/
export function updateFrontmatter(originalContent, updates) {
const parsed = matter(originalContent);
// Merge updates into existing frontmatter
const merged = {
...parsed.data,
...updates,
};
// Remove keys with undefined values (allows explicit removal of fields)
const newData = Object.fromEntries(Object.entries(merged).filter(([_, value]) => value !== undefined));
return matter.stringify(parsed.content, newData);
}
/**
* Update frontmatter in a file
*/
export function updateFrontmatterFile(filePath, updates) {
const content = fs.readFileSync(filePath, "utf8");
const updated = updateFrontmatter(content, updates);
fs.writeFileSync(filePath, updated, "utf8");
}
/**
* Check if a file has frontmatter
*/
export function hasFrontmatter(content) {
return content.trimStart().startsWith("---");
}
/**
* Create markdown with frontmatter
*/
export function createMarkdown(data, content) {
return stringifyMarkdown(data, content);
}
/**
* Write markdown file with frontmatter
*/
export function writeMarkdownFile(filePath, data, content) {
const markdown = createMarkdown(data, content);
fs.writeFileSync(filePath, markdown, "utf8");
}
/**
* Remove frontmatter from markdown content
*/
export function removeFrontmatter(content) {
const parsed = matter(content);
return parsed.content;
}
/**
* Get only frontmatter data from markdown
*/
export function getFrontmatter(content) {
const parsed = matter(content);
return parsed.data;
}
/**
* Parse feedback section from issue markdown content
* Looks for "## Spec Feedback Provided" section
*/
export function parseFeedbackSection(content) {
const feedback = [];
// Look for "## Spec Feedback Provided" section
const feedbackSectionMatch = content.match(/^## Spec Feedback Provided\s*$/m);
if (!feedbackSectionMatch) {
return feedback;
}
const startIndex = feedbackSectionMatch.index + feedbackSectionMatch[0].length;
// Find the end of this section (next ## heading or end of content)
const remainingContent = content.slice(startIndex);
const endMatch = remainingContent.match(/^## /m);
const sectionContent = endMatch
? remainingContent.slice(0, endMatch.index)
: remainingContent;
// Parse individual feedback items (### heading for each)
const feedbackPattern = /^### (FB-\d+) → ([a-z]+-\d+)(?: \((.*?)\))?\s*\n\*\*Type:\*\* (.+?)\s*\n\*\*Location:\*\* (.*?)\s*\n\*\*Status:\*\* (.+?)\s*\n\n([\s\S]*?)(?=\n###|$)/gm;
let match;
while ((match = feedbackPattern.exec(sectionContent)) !== null) {
const [, id, specId, specTitle, type, locationStr, status, content] = match;
// Parse location string: "## Section Name, line 45 ✓" or "line 45 ⚠" or "Unknown ✗"
const locationMatch = locationStr.match(/(?:(.+?),\s+)?line (\d+)\s*([✓⚠✗])/);
const feedbackData = {
id,
specId,
specTitle: specTitle || undefined,
type: type.trim(),
location: {
section: locationMatch?.[1]?.trim(),
line: locationMatch?.[2] ? parseInt(locationMatch[2]) : undefined,
status: locationMatch?.[3] === "✓"
? "valid"
: locationMatch?.[3] === "⚠"
? "relocated"
: "stale",
},
status: status.trim(),
content: content.trim(),
createdAt: "", // Would need to parse from content or get from DB
};
// Check for resolution
const resolutionMatch = content.match(/\*\*Resolution:\*\* (.+)/);
if (resolutionMatch) {
feedbackData.resolution = resolutionMatch[1].trim();
}
feedback.push(feedbackData);
}
return feedback;
}
/**
* Format feedback data for inclusion in issue markdown
*/
export function formatFeedbackForIssue(feedback) {
if (feedback.length === 0) {
return "";
}
let output = "\n## Spec Feedback Provided\n\n";
for (const fb of feedback) {
// Determine status indicator
const statusIndicator = fb.location.status === "valid"
? "✓"
: fb.location.status === "relocated"
? "⚠"
: "✗";
// Format location
let locationStr = "";
if (fb.location.section && fb.location.line) {
locationStr = `${fb.location.section}, line ${fb.location.line} ${statusIndicator}`;
}
else if (fb.location.line) {
locationStr = `line ${fb.location.line} ${statusIndicator}`;
}
else {
locationStr = `Unknown ${statusIndicator}`;
}
const titlePart = fb.specTitle ? ` (${fb.specTitle})` : "";
output += `### ${fb.id} → ${fb.specId}${titlePart}\n`;
output += `**Type:** ${fb.type} \n`;
output += `**Location:** ${locationStr} \n`;
output += `**Status:** ${fb.status}\n\n`;
output += `${fb.content}\n`;
if (fb.resolution) {
output += `\n**Resolution:** ${fb.resolution}\n`;
}
output += "\n";
}
return output;
}
/**
* Append or update feedback section in issue markdown
*/
export function updateFeedbackInIssue(issueContent, feedback) {
// Remove existing feedback section if present
const feedbackSectionMatch = issueContent.match(/^## Spec Feedback Provided\s*$/m);
if (feedbackSectionMatch) {
const startIndex = feedbackSectionMatch.index;
// Find the end of this section (next ## heading or end of content)
const remainingContent = issueContent.slice(startIndex);
const endMatch = remainingContent.match(/^## /m);
if (endMatch && endMatch.index > 0) {
// There's another section after feedback
const endIndex = startIndex + endMatch.index;
issueContent =
issueContent.slice(0, startIndex) + issueContent.slice(endIndex);
}
else {
// Feedback section is at the end
issueContent = issueContent.slice(0, startIndex);
}
}
// Append new feedback section
const feedbackMarkdown = formatFeedbackForIssue(feedback);
if (feedbackMarkdown) {
// Ensure there's a blank line before the new section
issueContent = issueContent.trimEnd() + "\n" + feedbackMarkdown;
}
return issueContent;
}
//# sourceMappingURL=markdown.js.map