Skip to content

플러그인 시스템

Docflow는 플러그인 시스템을 통해 문서 생성과 manifest 구조를 커스터마이징할 수 있어요. 덕분에 기본 VitePress 외에도 Nextra, Docusaurus 등 다양한 문서 시스템과 연동할 수 있어요.

플러그인 설정

설정 파일의 plugins 필드에 플러그인을 등록할 수 있어요.

javascript
// docflow.config.js (또는 .ts, .mjs)
export default {
  plugins: [
    {
      name: "nextra-plugin",
      plugin: createNextraPlugin,
      options: {
        signatureLanguage: "typescript",
      },
    },
  ],
  commands: {
    build: {
      generator: {
        name: "nextra", // 커스텀 제너레이터 사용
      },
    },
  },
};

플러그인 구조

플러그인은 다음과 같은 구조를 가져야 해요.

typescript
export interface Plugin {
  name: string;
  hooks: {
    transformManifest?: (
      manifest: SidebarItem[],
      context: PluginContext,
    ) => SidebarItem[];
    provideGenerator?: () => MarkdownGenerator;
  };
}

플러그인 훅

provideGenerator 훅

커스텀 Markdown 제너레이터를 제공해요. 덕분에 다양한 문서 프레임워크를 사용할 수 있게 돼요.

typescript
provideGenerator: () => MarkdownGenerator;

transformManifest 훅

생성된 manifest를 변환해서 파일로 저장할 수 있어요.

typescript
transformManifest: (manifest: SidebarItem[], context: PluginContext) => SidebarItem[]

Nextra 플러그인 만들기

예시로 Nextra 프레임워크에서도 사용할 수 있게 mdx로 변환하고, JSDoc 포맷을 수정하는 플러그인을 만들어볼게요. 아래 코드를 복사해서 바로 사용할 수 있어요.

전체 플러그인 코드
typescript
import {
  Plugin,
  ParsedJSDoc,
  TargetWithJSDoc,
  MarkdownDocument,
  MarkdownGenerator,
  GeneratedDoc,
  GeneratorConfig,
  MarkdownSection,
  SidebarItem,
  ParameterData,
  ReturnData,
  ExampleData,
} from "@docflow/cli";
import path from "path";

export class NextraGenerator implements MarkdownGenerator {
  private projectRoot: string;
  private signatureLanguage: string;

  constructor(config: GeneratorConfig) {
    this.projectRoot = config.projectRoot;
    this.signatureLanguage = config.signatureLanguage || "tsx";
  }

  generate(jsDocData: ParsedJSDoc, sourcePath?: string): MarkdownDocument {
    const sections: MarkdownSection[] = [];

    if (jsDocData.description) {
      sections.push({
        type: "description",
        content: jsDocData.description,
      });
    }

    if (jsDocData.signature) {
      sections.push(this.createSignatureSection(jsDocData.signature));
    }

    if (jsDocData.parameters.length > 0) {
      sections.push(this.createParametersSection(jsDocData.parameters));
    }

    if (jsDocData.returns) {
      sections.push(this.createReturnsSection(jsDocData.returns));
    }

    if (jsDocData.examples && jsDocData.examples.length > 0) {
      sections.push(this.createExamplesSection(jsDocData.examples));
    }

    return {
      frontmatter: this.createFrontmatter(jsDocData.name, sourcePath),
      sections,
    };
  }

  serialize(markdownDoc: MarkdownDocument): string {
    const parts: string[] = [];

    if (
      markdownDoc.frontmatter &&
      Object.keys(markdownDoc.frontmatter).length > 0
    ) {
      parts.push("---");
      for (const [key, value] of Object.entries(markdownDoc.frontmatter)) {
        if (typeof value === "string") {
          parts.push(`${key}: "${value}"`);
        } else {
          parts.push(`${key}: ${JSON.stringify(value)}`);
        }
      }
      parts.push("---");
      parts.push("");
    }

    for (const section of markdownDoc.sections) {
      parts.push(section.content);
      parts.push("");
    }

    return parts.join("\n").trim() + "\n";
  }

  generateDocs(target: TargetWithJSDoc, packagePath: string): GeneratedDoc {
    const filePath = target.filePath;
    const relativePath = this.generateRelativePath(target, packagePath);
    const markdownDoc = this.generate(target.parsedJSDoc, target.filePath);
    const content = this.serialize(markdownDoc);

    return {
      filePath,
      content,
      relativePath,
    };
  }

  private generateRelativePath(
    target: TargetWithJSDoc,
    packagePath: string,
  ): string {
    const { parsedJSDoc: jsDocData, symbolName, kind } = target;
    const category = jsDocData.category || kind || "misc";
    const name = jsDocData.name || symbolName;
    const packageFolder = path.basename(packagePath);

    return path.join(packageFolder, category, `${name}.mdx`);
  }

  private createFrontmatter(
    title?: string,
    sourcePath?: string,
  ): Record<string, unknown> {
    const frontmatter: Record<string, unknown> = {};

    if (title) {
      frontmatter.title = title;
    }

    if (sourcePath) {
      frontmatter.source = this.getRelativeSourcePath(sourcePath);
    }

    return frontmatter;
  }

  private getRelativeSourcePath(absolutePath: string): string {
    if (!this.projectRoot) {
      return absolutePath;
    }

    const relativePath = path.relative(this.projectRoot, absolutePath);
    return relativePath.startsWith("..") ? absolutePath : relativePath;
  }

  private createSignatureSection(signature: string): MarkdownSection {
    const normalizedSignature = signature
      .trim()
      .replace(/```\w*\n/g, "")
      .replace(/```/g, "")
      .trim();

    return {
      type: "signature",
      content: `## 사용법\n\n\`\`\`${this.signatureLanguage}\n${normalizedSignature}\n\`\`\``,
    };
  }

  private createParametersSection(
    parameters: ParameterData[],
  ): MarkdownSection {
    const content = ["## 매개변수", ""];

    content.push("| 이름 | 타입 | 설명 |");
    content.push("|------|------|------|");

    for (const param of parameters) {
      const required = param.required ? " (필수)" : " (선택)";
      content.push(
        `| \`${param.name}\` | \`${param.type}\` | ${param.description}${required} |`,
      );
    }

    return {
      type: "parameters",
      content: content.join("\n"),
    };
  }

  private createReturnsSection(returns: ReturnData): MarkdownSection {
    return {
      type: "returns",
      content: `## 반환값\n\n**\`${returns.type}\`** - ${returns.description}`,
    };
  }

  private createExamplesSection(examples: ExampleData[]): MarkdownSection {
    const content = ["## 예제", ""];

    for (const example of examples) {
      content.push(example.code);
      content.push("");
    }

    return {
      type: "examples",
      content: content.join("\n"),
    };
  }
}

function transformForNextra(
  manifest: SidebarItem[],
): Record<string, string | { type: string; title: string }> {
  const meta: Record<string, string | { type: string; title: string }> = {};

  function processItems(items: SidebarItem[], prefix = "") {
    for (const item of items) {
      if (item.link) {
        const key = item.link.replace(".md", "").replace(".mdx", "");
        meta[key] = item.text;
      } else if (item.items) {
        meta[item.text] = {
          type: "folder",
          title: item.text,
        };
        processItems(item.items, `${prefix}${item.text}/`);
      }
    }
  }

  processItems(manifest);
  return meta;
}

export function createNextraPlugin(options: Record<string, unknown> = {}) {
  return {
    name: "nextra",
    hooks: {
      provideGenerator: () => {
        return new NextraGenerator({
          name: "nextra",
          projectRoot: process.cwd(),
          signatureLanguage: "typescript",
          ...options,
        });
      },
      transformManifest: (manifest: SidebarItem[]) => {
        return transformForNextra(manifest);
      },
    },
  } satisfies Plugin;
}

설정 파일에서 사용하기

javascript
import { createNextraPlugin } from "./plugins/nextra-plugin.js";

export default {
  plugins: [
    {
      name: "nextra-plugin",
      plugin: createNextraPlugin,
      options: {
        signatureLanguage: "typescript",
      },
    },
  ],
  commands: {
    build: {
      outputDir: "docs/pages", // Nextra의 기본 페이지 디렉토리
      generator: {
        name: "nextra",
      },
      manifest: {
        enabled: true,
        path: "docs/pages/_meta.json", // Nextra의 네비게이션 파일
      },
    },
  },
};

마무리

Nextra 플러그인을 사용하여 문서를 생성해보세요.

bash
npx docflow build

docs/pages/
├── math/
   └── calculateArea.mdx
└── _meta.json

MIT 라이선스에 따라 배포됩니다.