adf.parser.ts•9.13 kB
/**
 * ADF (Atlassian Document Format) Parser
 *
 * Converts JIRA's complex ADF object structures to readable markdown text.
 * Handles backward compatibility with string descriptions.
 */
/**
 * ADF Node structure representing Atlassian Document Format nodes
 */
export interface ADFNode {
  type: string;
  content?: ADFNode[];
  text?: string;
  attrs?: Record<string, unknown>;
  marks?: Array<{ type: string; attrs?: Record<string, unknown> }>;
}
/**
 * ADF Document structure (top-level document with version)
 */
export interface ADFDocument extends ADFNode {
  type: "doc";
  version: number;
  content: ADFNode[];
}
/**
 * Text mark for formatting (bold, italic, code, etc.)
 */
export interface ADFMark {
  type: string;
  attrs?: Record<string, unknown>;
}
/**
 * Type guard to check if value is a string
 */
function isString(value: unknown): value is string {
  return typeof value === "string";
}
/**
 * Type guard to check if value is null or undefined
 */
function isNullish(value: unknown): value is null | undefined {
  return value === null || value === undefined;
}
/**
 * Type guard to check if value is an object (not null)
 */
function isObject(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null;
}
/**
 * Type guard to check if value is an ADF node
 */
function isADFNode(value: unknown): value is ADFNode {
  return isObject(value) && "type" in value && isString(value.type);
}
/**
 * Type guard to check if value is an ADF document
 */
function isADFDocument(value: unknown): value is ADFDocument {
  return (
    isADFNode(value) &&
    value.type === "doc" &&
    "version" in value &&
    typeof value.version === "number" &&
    "content" in value &&
    Array.isArray(value.content)
  );
}
/**
 * Type guard to check if value is a non-document ADF node
 */
function isNonDocumentADFNode(value: unknown): value is ADFNode {
  return isADFNode(value) && value.type !== "doc";
}
/**
 * Type guard to check if string is empty or whitespace only
 */
function isEmptyString(value: string): boolean {
  return value.trim() === "";
}
/**
 * Converts ADF objects to markdown format preserving structure and formatting
 */
export class ADFToMarkdownParser {
  /**
   * Parse ADF content to markdown
   * @param adf - ADF object or string (for backward compatibility)
   * @returns Formatted markdown string
   */
  parse(adf: ADFNode | string | null | undefined): string {
    // Handle backward compatibility with string descriptions
    if (isString(adf)) return adf;
    if (isNullish(adf)) return "";
    return this.parseNode(adf);
  }
  /**
   * Parse a single ADF node
   */
  private parseNode(node: ADFNode): string {
    switch (node.type) {
      case "doc":
        return this.parseContent(node.content || []);
      case "paragraph":
        return `${this.parseContent(node.content || [])}\n\n`;
      case "text":
        return this.formatText(node.text || "", node.marks || []);
      case "codeBlock":
        return this.parseCodeBlock(node);
      case "bulletList":
        return this.parseBulletList(node);
      case "orderedList":
        return this.parseOrderedList(node);
      case "listItem":
        return this.parseListItem(node);
      case "heading":
        return this.parseHeading(node);
      case "blockquote":
        return this.parseBlockquote(node);
      case "hardBreak":
        return "\n";
      case "rule":
        return "\n---\n\n";
      default:
        // For unknown node types, try to parse content if available
        return this.parseContent(node.content || []);
    }
  }
  /**
   * Parse array of content nodes
   */
  private parseContent(content: ADFNode[]): string {
    return content.map((node) => this.parseNode(node)).join("");
  }
  /**
   * Format text with marks (bold, italic, code, etc.)
   */
  private formatText(text: string, marks: ADFMark[]): string {
    if (!marks || marks.length === 0) return text;
    let formattedText = text;
    // Apply marks in order
    for (const mark of marks) {
      switch (mark.type) {
        case "strong":
          formattedText = `**${formattedText}**`;
          break;
        case "em":
          formattedText = `*${formattedText}*`;
          break;
        case "code":
          formattedText = `\`${formattedText}\``;
          break;
        case "strike":
          formattedText = `~~${formattedText}~~`;
          break;
        case "link": {
          const href = mark.attrs?.href;
          if (href && isString(href)) {
            formattedText = `[${formattedText}](${href})`;
          }
          break;
        }
        // Add more mark types as needed
        default:
          // Unknown mark type, keep text as-is
          break;
      }
    }
    return formattedText;
  }
  /**
   * Parse code block with language support
   */
  private parseCodeBlock(node: ADFNode): string {
    const language = node.attrs?.language || "";
    const content = this.parseContent(node.content || []);
    return `\`\`\`${language}\n${content}\n\`\`\`\n\n`;
  }
  /**
   * Parse bullet list
   */
  private parseBulletList(node: ADFNode): string {
    const items = (node.content || [])
      .map((item) => this.parseListItem(item, "- "))
      .join("");
    return `${items}\n`;
  }
  /**
   * Parse ordered list
   */
  private parseOrderedList(node: ADFNode): string {
    const items = (node.content || [])
      .map((item, index) => this.parseListItem(item, `${index + 1}. `))
      .join("");
    return `${items}\n`;
  }
  /**
   * Parse list item with prefix
   */
  private parseListItem(node: ADFNode, prefix = "- "): string {
    const content = this.parseContent(node.content || []);
    return `${prefix}${content.trim()}\n\n`;
  }
  /**
   * Parse heading with level support
   */
  private parseHeading(node: ADFNode): string {
    const level = node.attrs?.level || 1;
    const hashes = "#".repeat(Math.min(level as number, 6));
    const content = this.parseContent(node.content || []);
    return `${hashes} ${content}\n\n`;
  }
  /**
   * Parse blockquote
   */
  private parseBlockquote(node: ADFNode): string {
    const content = this.parseContent(node.content || []);
    const lines = content.split("\n");
    const quotedLines = lines.map((line) => `> ${line}`).join("\n");
    return `${quotedLines}\n\n`;
  }
  /**
   * Extract plain text only (alternative format for simple use cases)
   */
  extractPlainText(adf: ADFNode | string | null | undefined): string {
    if (isString(adf)) return adf;
    if (isNullish(adf)) return "";
    return this.extractTextFromNode(adf);
  }
  /**
   * Recursively extract text from ADF node
   */
  private extractTextFromNode(node: ADFNode): string {
    if (node.type === "text") {
      return node.text || "";
    }
    if (node.content) {
      return node.content
        .map((childNode) => this.extractTextFromNode(childNode))
        .join("");
    }
    return "";
  }
}
/**
 * Default parser instance for convenience
 */
export const adfParser = new ADFToMarkdownParser();
/**
 * Convenience function for parsing ADF to markdown
 * @param adf - ADF object or string
 * @returns Formatted markdown string
 */
export function parseADF(adf: ADFNode | string | null | undefined): string {
  return adfParser.parse(adf);
}
/**
 * Convenience function for extracting plain text from ADF
 * @param adf - ADF object or string
 * @returns Plain text string
 */
export function extractTextFromADF(
  adf: ADFNode | string | null | undefined,
): string {
  return adfParser.extractPlainText(adf);
}
/**
 * Convert plain text to ADF document format
 * @param text - Plain text string
 * @returns ADF document structure
 */
export function textToADF(text: string | null | undefined): ADFDocument | null {
  if (isNullish(text) || isEmptyString(text)) {
    return null;
  }
  // Split text into paragraphs (by double newlines or single newlines)
  const paragraphs = text
    .split(/\n\s*\n/)
    .map((p) => p.trim())
    .filter((p) => p.length > 0);
  if (paragraphs.length === 0) {
    return null;
  }
  const content: ADFNode[] = paragraphs.map((paragraph) => ({
    type: "paragraph",
    content: [
      {
        type: "text",
        text: paragraph,
      },
    ],
  }));
  return {
    version: 1,
    type: "doc",
    content,
  };
}
/**
 * Convert text to ADF or return existing ADF if already in correct format
 * @param input - Text string or existing ADF document/node
 * @returns ADF document structure or null
 */
export function ensureADFFormat(
  input: string | ADFDocument | ADFNode | null | undefined,
): ADFDocument | null {
  if (isNullish(input)) {
    return null;
  }
  // If it's already an ADF document, return as-is
  if (isADFDocument(input)) {
    return input;
  }
  // If it's an ADF node but not a document, wrap it in a document
  if (isNonDocumentADFNode(input)) {
    return {
      version: 1,
      type: "doc",
      content: [input],
    };
  }
  // If it's a string, convert to ADF
  if (isString(input)) {
    return textToADF(input);
  }
  return null;
}