Custom Plugin Development 
Learn how to create custom publisher plugins for ArtiPub to integrate with any platform or service.
Plugin Architecture 
Core Interface 
Every plugin must implement the PublisherPlugin interface:
ts
interface PublisherPlugin {
  /**
   * Unique identifier for the plugin
   */
  name: string;
  /**
   * Enable article update tracking
   * When true, ArtiPub saves post IDs for future updates
   */
  isTraceUpdate?: boolean;
  /**
   * Receive tracked data for updates
   * Called before process() if article was previously published
   * Returns itself for method chaining
   */
  extendsParam?: (param: { pid?: string }) => PublisherPlugin;
  /**
   * Main processing function
   * Transforms and publishes the article
   */
  process: (articleTitle: string, visit: TVisitor, toMarkdown: () => { content: string }) => Promise<PublishResult>;
}
interface PublishResult {
  success: boolean;
  info?: string; // Success/error message
  pid?: string; // Platform post ID for tracking
}Process Function Parameters 
| Parameter | Type | Description | 
|---|---|---|
articleTitle | string | Extracted from first H1 heading | 
visit | TVisitor | AST visitor for content transformation | 
toMarkdown | Function | Converts AST back to Markdown string | 
Basic Plugin Template 
ts
import { PublisherPlugin, PublishResult } from "@artipub/core";
interface MyPluginOptions {
  apiKey: string;
  endpoint?: string;
  // Add your configuration options
}
export function MyPublisherPlugin(options: MyPluginOptions): PublisherPlugin {
  // Validate options
  if (!options.apiKey) {
    throw new Error("API key is required");
  }
  // Default values
  const config = {
    endpoint: "https://api.example.com",
    ...options,
  };
  return {
    name: "MyPublisher",
    isTraceUpdate: true, // Enable update tracking
    async process(articleTitle, visit, toMarkdown) {
      try {
        // 1. Transform content if needed
        transformContent(visit);
        // 2. Convert to markdown
        const { content } = toMarkdown();
        // 3. Publish to platform
        const result = await publishToPlatform(articleTitle, content, config);
        // 4. Return result
        return {
          success: true,
          info: `Published to ${result.url}`,
          pid: result.id, // Save for updates
        };
      } catch (error) {
        return {
          success: false,
          info: error.message,
        };
      }
    },
  };
}Real-World Examples 
Medium Publisher Plugin 
ts
import { PublisherPlugin } from "@artipub/core";
import axios from "axios";
interface MediumOptions {
  accessToken: string;
  userId: string;
  publishStatus?: "draft" | "public" | "unlisted";
  tags?: string[];
  canonicalUrl?: string;
  license?:
    | "all-rights-reserved"
    | "cc-40-by"
    | "cc-40-by-sa"
    | "cc-40-by-nd"
    | "cc-40-by-nc"
    | "cc-40-by-nc-nd"
    | "cc-40-by-nc-sa"
    | "cc-40-zero"
    | "public-domain";
}
export function MediumPublisherPlugin(options: MediumOptions): PublisherPlugin {
  let existingPostId: string | undefined;
  return {
    name: "Medium",
    isTraceUpdate: true,
    extendsParam(params) {
      existingPostId = params.pid;
      return this;
    },
    async process(articleTitle, visit, toMarkdown) {
      try {
        // Transform for Medium's requirements
        visit("code", (node) => {
          // Medium doesn't support language hints
          if (node.lang) {
            node.lang = null;
          }
        });
        // Remove HTML (Medium doesn't support it)
        visit("html", (node, index, parent) => {
          parent.children.splice(index, 1);
        });
        // Add Medium-specific formatting
        visit("image", (node) => {
          // Ensure alt text exists
          if (!node.alt) {
            node.alt = "Image";
          }
        });
        const { content } = toMarkdown();
        const postData = {
          title: articleTitle,
          contentFormat: "markdown",
          content: content,
          publishStatus: options.publishStatus || "draft",
          tags: options.tags || [],
          canonicalUrl: options.canonicalUrl,
          license: options.license || "all-rights-reserved",
        };
        let response;
        if (existingPostId) {
          // Update existing post (Note: Medium API doesn't support updates)
          return {
            success: false,
            info: "Medium API doesn't support post updates",
          };
        } else {
          // Create new post
          response = await axios.post(`https://api.medium.com/v1/users/${options.userId}/posts`, postData, {
            headers: {
              Authorization: `Bearer ${options.accessToken}`,
              "Content-Type": "application/json",
              Accept: "application/json",
            },
          });
        }
        return {
          success: true,
          info: `Published to Medium: ${response.data.data.url}`,
          pid: response.data.data.id,
        };
      } catch (error) {
        if (axios.isAxiosError(error)) {
          return {
            success: false,
            info: `Medium API error: ${error.response?.data?.errors?.[0]?.message || error.message}`,
          };
        }
        return {
          success: false,
          info: `Unexpected error: ${error.message}`,
        };
      }
    },
  };
}WordPress Publisher Plugin 
ts
import { PublisherPlugin } from "@artipub/core";
import axios from "axios";
interface WordPressOptions {
  siteUrl: string;
  username: string;
  applicationPassword: string;
  status?: "publish" | "draft" | "pending" | "private";
  categories?: number[];
  tags?: number[];
  featuredMedia?: number;
}
export function WordPressPublisherPlugin(options: WordPressOptions): PublisherPlugin {
  let existingPostId: string | undefined;
  const auth = Buffer.from(`${options.username}:${options.applicationPassword}`).toString("base64");
  return {
    name: "WordPress",
    isTraceUpdate: true,
    extendsParam(params) {
      existingPostId = params.pid;
      return this;
    },
    async process(articleTitle, visit, toMarkdown) {
      try {
        // Transform content for WordPress
        let featuredImageUrl: string | undefined;
        // Extract first image as featured
        visit("image", (node, index, parent) => {
          if (!featuredImageUrl) {
            featuredImageUrl = node.url;
          }
        });
        // Convert markdown to HTML (WordPress prefers HTML)
        const { content: markdownContent } = toMarkdown();
        // You might want to convert to HTML here
        // const htmlContent = await markdownToHtml(markdownContent);
        const postData = {
          title: articleTitle,
          content: markdownContent, // or htmlContent
          status: options.status || "draft",
          categories: options.categories || [],
          tags: options.tags || [],
          featured_media: options.featuredMedia,
        };
        let response;
        const endpoint = `${options.siteUrl}/wp-json/wp/v2/posts`;
        if (existingPostId) {
          // Update existing post
          response = await axios.put(`${endpoint}/${existingPostId}`, postData, {
            headers: {
              Authorization: `Basic ${auth}`,
              "Content-Type": "application/json",
            },
          });
        } else {
          // Create new post
          response = await axios.post(endpoint, postData, {
            headers: {
              Authorization: `Basic ${auth}`,
              "Content-Type": "application/json",
            },
          });
        }
        return {
          success: true,
          info: `Published to WordPress: ${response.data.link}`,
          pid: response.data.id.toString(),
        };
      } catch (error) {
        if (axios.isAxiosError(error)) {
          return {
            success: false,
            info: `WordPress API error: ${error.response?.data?.message || error.message}`,
          };
        }
        return {
          success: false,
          info: `Unexpected error: ${error.message}`,
        };
      }
    },
  };
}Ghost Publisher Plugin 
ts
import { PublisherPlugin } from "@artipub/core";
import jwt from "jsonwebtoken";
import axios from "axios";
interface GhostOptions {
  url: string;
  adminApiKey: string;
  status?: "published" | "draft" | "scheduled";
  tags?: string[];
  featured?: boolean;
  visibility?: "public" | "members" | "paid";
}
export function GhostPublisherPlugin(options: GhostOptions): PublisherPlugin {
  let existingPostId: string | undefined;
  // Parse the Admin API key
  const [id, secret] = options.adminApiKey.split(":");
  // Create JWT token for authentication
  const token = jwt.sign({}, Buffer.from(secret, "hex"), {
    keyid: id,
    algorithm: "HS256",
    expiresIn: "5m",
    audience: `/v3/admin/`,
  });
  return {
    name: "Ghost",
    isTraceUpdate: true,
    extendsParam(params) {
      existingPostId = params.pid;
      return this;
    },
    async process(articleTitle, visit, toMarkdown) {
      try {
        // Transform content
        const { content } = toMarkdown();
        // Convert markdown to Mobiledoc (Ghost's format)
        // You might need a markdown-to-mobiledoc converter
        const postData = {
          posts: [
            {
              title: articleTitle,
              markdown: content,
              status: options.status || "draft",
              tags: options.tags,
              featured: options.featured || false,
              visibility: options.visibility || "public",
            },
          ],
        };
        let response;
        const endpoint = `${options.url}/ghost/api/v3/admin/posts`;
        if (existingPostId) {
          // Update existing post
          response = await axios.put(`${endpoint}/${existingPostId}`, postData, {
            headers: {
              Authorization: `Ghost ${token}`,
              "Content-Type": "application/json",
            },
          });
        } else {
          // Create new post
          response = await axios.post(endpoint, postData, {
            headers: {
              Authorization: `Ghost ${token}`,
              "Content-Type": "application/json",
            },
          });
        }
        return {
          success: true,
          info: `Published to Ghost: ${response.data.posts[0].url}`,
          pid: response.data.posts[0].id,
        };
      } catch (error) {
        return {
          success: false,
          info: `Ghost API error: ${error.message}`,
        };
      }
    },
  };
}AST Transformation 
Understanding the Visitor Pattern 
The visit function allows you to traverse and modify the Markdown AST:
ts
// Visit all nodes of a specific type
visit("heading", (node, index, parent) => {
  console.log("Found heading:", node);
});
// Visit with test function
visit(
  (node) => node.type === "heading" && node.depth === 1,
  (node) => {
    console.log("Found H1:", node);
  }
);
// Remove nodes
visit("html", (node, index, parent) => {
  parent.children.splice(index, 1);
});
// Modify nodes
visit("image", (node) => {
  node.url = transformImageUrl(node.url);
  node.alt = node.alt || "Image";
});Common Node Types 
ts
// Text node
interface Text {
  type: 'text';
  value: string;
}
// Heading node
interface Heading {
  type: 'heading';
  depth: 1 | 2 | 3 | 4 | 5 | 6;
  children: Array<Text | Link | ...>;
}
// Image node
interface Image {
  type: 'image';
  url: string;
  alt?: string;
  title?: string;
}
// Code block node
interface Code {
  type: 'code';
  lang?: string;
  value: string;
}
// Link node
interface Link {
  type: 'link';
  url: string;
  children: Array<Text | ...>;
}Transformation Examples 
ts
function transformContent(visit: TVisitor) {
  // Add IDs to headings
  let headingCounter = 0;
  visit("heading", (node) => {
    headingCounter++;
    const id = `heading-${headingCounter}`;
    // Add ID as HTML comment (platform-specific)
    const idComment = {
      type: "html",
      value: `<!-- id="${id}" -->`,
    };
    node.children.unshift(idComment);
  });
  // Transform relative URLs to absolute
  const baseUrl = "https://example.com";
  visit("link", (node) => {
    if (node.url.startsWith("./") || node.url.startsWith("../")) {
      node.url = new URL(node.url, baseUrl).href;
    }
  });
  // Add lazy loading to images
  visit("image", (node) => {
    node.data = node.data || {};
    node.data.hProperties = node.data.hProperties || {};
    node.data.hProperties.loading = "lazy";
  });
  // Convert emoji shortcodes
  visit("text", (node) => {
    node.value = node.value.replace(/:(\w+):/g, (match, emoji) => {
      const emojiMap = {
        smile: "😊",
        heart: "❤️",
        thumbsup: "👍",
      };
      return emojiMap[emoji] || match;
    });
  });
}Advanced Features 
Update Tracking 
Implement article update support:
ts
export function MyPlugin(options: MyOptions): PublisherPlugin {
  let existingPostId: string | undefined;
  return {
    name: "MyPlatform",
    isTraceUpdate: true, // Enable tracking
    extendsParam(params) {
      // Receive existing post ID
      existingPostId = params.pid;
      console.log("Updating existing post:", existingPostId);
    },
    async process(articleTitle, visit, toMarkdown) {
      const { content } = toMarkdown();
      if (existingPostId) {
        // Update existing post
        const result = await updatePost(existingPostId, {
          title: articleTitle,
          content: content,
        });
        return {
          success: true,
          info: `Updated post: ${result.url}`,
          pid: existingPostId, // Keep the same ID
        };
      } else {
        // Create new post
        const result = await createPost({
          title: articleTitle,
          content: content,
        });
        return {
          success: true,
          info: `Created post: ${result.url}`,
          pid: result.id, // Save new ID
        };
      }
    },
  };
}Error Handling Patterns 
ts
async process(articleTitle, visit, toMarkdown) {
  try {
    // Validate input
    if (!articleTitle || articleTitle.trim().length === 0) {
      throw new Error('Article title is required');
    }
    // Transform content
    const { content } = toMarkdown();
    if (!content || content.trim().length === 0) {
      throw new Error('Article content is empty');
    }
    // API call with timeout
    const controller = new AbortController();
    const timeout = setTimeout(() => controller.abort(), 30000);
    try {
      const response = await fetch(apiUrl, {
        method: 'POST',
        body: JSON.stringify({ title: articleTitle, content }),
        signal: controller.signal
      });
      if (!response.ok) {
        throw new Error(`API error: ${response.status} ${response.statusText}`);
      }
      const data = await response.json();
      return {
        success: true,
        info: `Published: ${data.url}`,
        pid: data.id
      };
    } finally {
      clearTimeout(timeout);
    }
  } catch (error) {
    // Categorize errors
    if (error.name === 'AbortError') {
      return {
        success: false,
        info: 'Request timeout - platform took too long to respond'
      };
    }
    if (error.message.includes('API error')) {
      return {
        success: false,
        info: error.message
      };
    }
    // Log unexpected errors
    console.error('Unexpected error in plugin:', error);
    return {
      success: false,
      info: `Failed to publish: ${error.message}`
    };
  }
}Rate Limiting 
ts
import { RateLimiter } from "limiter";
const limiter = new RateLimiter({
  tokensPerInterval: 10,
  interval: "minute",
});
export function RateLimitedPlugin(options: Options): PublisherPlugin {
  return {
    name: "RateLimited",
    async process(articleTitle, visit, toMarkdown) {
      // Wait for rate limit token
      await limiter.removeTokens(1);
      // Continue with publishing
      return publish(articleTitle, toMarkdown());
    },
  };
}Caching 
ts
const cache = new Map<string, PublishResult>();
export function CachedPlugin(options: Options): PublisherPlugin {
  return {
    name: "Cached",
    async process(articleTitle, visit, toMarkdown) {
      const { content } = toMarkdown();
      const cacheKey = `${articleTitle}-${hashContent(content)}`;
      // Check cache
      if (cache.has(cacheKey)) {
        const cachedResult = cache.get(cacheKey)!;
        console.log("Using cached result");
        return cachedResult;
      }
      // Publish
      const result = await publish(articleTitle, content);
      // Cache successful results
      if (result.success) {
        cache.set(cacheKey, result);
      }
      return result;
    },
  };
}Testing Your Plugin 
Unit Testing 
ts
import { describe, it, expect, vi } from "vitest";
import { MyPublisherPlugin } from "./my-plugin";
describe("MyPublisherPlugin", () => {
  it("should create plugin with valid config", () => {
    const plugin = MyPublisherPlugin({
      apiKey: "test-key",
    });
    expect(plugin.name).toBe("MyPublisher");
    expect(plugin.isTraceUpdate).toBe(true);
    expect(plugin.process).toBeInstanceOf(Function);
  });
  it("should throw error without API key", () => {
    expect(() => MyPublisherPlugin({})).toThrow("API key is required");
  });
  it("should publish article successfully", async () => {
    const plugin = MyPublisherPlugin({
      apiKey: "test-key",
    });
    const mockVisit = vi.fn();
    const mockToMarkdown = () => ({ content: "# Test\nContent" });
    const result = await plugin.process("Test Article", mockVisit, mockToMarkdown);
    expect(result.success).toBe(true);
    expect(result.pid).toBeDefined();
  });
  it("should handle API errors gracefully", async () => {
    const plugin = MyPublisherPlugin({
      apiKey: "invalid-key",
    });
    const result = await plugin.process("Test Article", vi.fn(), () => ({ content: "Content" }));
    expect(result.success).toBe(false);
    expect(result.info).toContain("error");
  });
});Integration Testing 
ts
import { ArticleProcessor, PublisherManager } from "@artipub/core";
import { MyPublisherPlugin } from "./my-plugin";
describe("Integration Tests", () => {
  it("should work with PublisherManager", async () => {
    const processor = new ArticleProcessor({
      uploadImgOption: async () => "https://example.com/image.jpg",
    });
    const { content } = await processor.processMarkdown("./test.md");
    const publisher = new PublisherManager(content);
    publisher.addPlugin(
      MyPublisherPlugin({
        apiKey: process.env.TEST_API_KEY!,
      })
    );
    const results = await publisher.publish();
    expect(results[0].success).toBe(true);
  });
});Publishing Your Plugin 
Package Structure 
my-artipub-plugin/
├── src/
│   ├── index.ts         # Main plugin export
│   ├── types.ts         # TypeScript types
│   └── utils.ts         # Helper functions
├── test/
│   └── plugin.test.ts   # Tests
├── package.json
├── tsconfig.json
└── README.mdPackage.json 
json
{
  "name": "artipub-plugin-myplatform",
  "version": "1.0.0",
  "description": "MyPlatform publisher plugin for ArtiPub",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "scripts": {
    "build": "tsc",
    "test": "vitest",
    "prepublishOnly": "npm run build"
  },
  "keywords": ["artipub", "artipub-plugin", "publisher", "myplatform"],
  "peerDependencies": {
    "@artipub/core": "^1.0.0"
  },
  "devDependencies": {
    "@artipub/core": "^1.0.0",
    "typescript": "^5.0.0",
    "vitest": "^0.34.0"
  }
}Export Pattern 
ts
// src/index.ts
export { MyPublisherPlugin } from "./plugin";
export type { MyPluginOptions } from "./types";
// Usage by consumers
import { MyPublisherPlugin } from "artipub-plugin-myplatform";Best Practices 
- Validate Options Early: Check required fields in the factory function
 - Handle Errors Gracefully: Always return 
PublishResulteven on failure - Use TypeScript: Provide type definitions for better developer experience
 - Document Platform Requirements: List supported features and limitations
 - Test Thoroughly: Include unit and integration tests
 - Version Compatibility: Use peerDependencies for @artipub/core
 - Logging: Add optional debug logging for troubleshooting
 - Respect Rate Limits: Implement proper rate limiting for APIs
 - Security: Never log sensitive data like API keys
 - Idempotency: Make operations idempotent when possible