Complete Phase 1 foundation: Tasks 1.1 and 1.2

 Task 1.1 - Project Foundation Setup:
- Repository structure with Python packaging (setup.py, pyproject.toml)
- Development dependencies and requirements
- Contributing guidelines and MIT license
- GitHub workflows for CI/CD (test.yml, release.yml)
- Issue and PR templates for community contributions
- Comprehensive project documentation

 Task 1.2 - Core Client Structure:
- wikijs package with proper module organization
- Core client class foundation in client.py
- Exception hierarchy for error handling
- Base model classes and page models
- Type checking support (py.typed)
- Utility modules and helper functions

📊 Progress: Phase 1 MVP Development now 40% complete
🎯 Next: Task 1.3 - Authentication System implementation

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-07-29 13:25:36 -04:00
parent 3554d7d69c
commit 11b6be87c8
31 changed files with 3805 additions and 115 deletions

77
wikijs/__init__.py Normal file
View File

@@ -0,0 +1,77 @@
"""Wiki.js Python SDK - Professional SDK for Wiki.js API integration.
This package provides a comprehensive Python SDK for interacting with Wiki.js
instances, including support for pages, users, groups, and system management.
Example:
Basic usage:
>>> from wikijs import WikiJSClient
>>> client = WikiJSClient('https://wiki.example.com', auth='your-api-key')
>>> # API endpoints will be available as development progresses
Features:
- Type-safe data models with validation
- Comprehensive error handling
- Automatic retry logic with exponential backoff
- Professional logging and debugging support
- Context manager support for resource cleanup
"""
from .client import WikiJSClient
from .exceptions import (
WikiJSException,
APIError,
AuthenticationError,
ConfigurationError,
ValidationError,
ClientError,
ServerError,
NotFoundError,
PermissionError,
RateLimitError,
ConnectionError,
TimeoutError,
)
from .models import BaseModel, Page, PageCreate, PageUpdate
from .version import __version__, __version_info__
# Public API
__all__ = [
# Main client
"WikiJSClient",
# Data models
"BaseModel",
"Page",
"PageCreate",
"PageUpdate",
# Exceptions
"WikiJSException",
"APIError",
"AuthenticationError",
"ConfigurationError",
"ValidationError",
"ClientError",
"ServerError",
"NotFoundError",
"PermissionError",
"RateLimitError",
"ConnectionError",
"TimeoutError",
# Version info
"__version__",
"__version_info__",
]
# Package metadata
__author__ = "Wiki.js SDK Contributors"
__email__ = ""
__license__ = "MIT"
__description__ = "Professional Python SDK for Wiki.js API integration"
__url__ = "https://github.com/yourusername/wikijs-python-sdk"
# For type checking
__all__ += ["__author__", "__email__", "__license__", "__description__", "__url__"]

19
wikijs/auth/__init__.py Normal file
View File

@@ -0,0 +1,19 @@
"""Authentication module for wikijs-python-sdk.
This module will contain authentication handlers for different
authentication methods supported by Wiki.js.
Future implementations:
- API key authentication
- JWT token authentication
- OAuth2 authentication
"""
# Placeholder for future authentication implementations
# from .base import AuthHandler
# from .api_key import APIKeyAuth
# from .jwt import JWTAuth
__all__ = [
# Will be implemented in Task 1.3
]

261
wikijs/client.py Normal file
View File

@@ -0,0 +1,261 @@
"""Main WikiJS client for wikijs-python-sdk."""
import json
from typing import Any, Dict, Optional, Union
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from .exceptions import (
APIError,
AuthenticationError,
ConfigurationError,
ConnectionError,
TimeoutError,
create_api_error,
)
from .utils import normalize_url, build_api_url, parse_wiki_response, extract_error_message
class WikiJSClient:
"""Main client for interacting with Wiki.js API.
This client provides a high-level interface for all Wiki.js API operations
including pages, users, groups, and system management. It handles authentication,
error handling, and response parsing automatically.
Args:
base_url: The base URL of your Wiki.js instance
auth: Authentication (API key string or auth handler)
timeout: Request timeout in seconds (default: 30)
verify_ssl: Whether to verify SSL certificates (default: True)
user_agent: Custom User-Agent header
Example:
Basic usage with API key:
>>> client = WikiJSClient('https://wiki.example.com', auth='your-api-key')
>>> # Will be available after endpoints are implemented:
>>> # pages = client.pages.list()
Attributes:
base_url: The normalized base URL
timeout: Request timeout setting
verify_ssl: SSL verification setting
"""
def __init__(
self,
base_url: str,
auth: Union[str, "AuthHandler"],
timeout: int = 30,
verify_ssl: bool = True,
user_agent: Optional[str] = None,
):
# Validate and normalize base URL
self.base_url = normalize_url(base_url)
# Store authentication
if isinstance(auth, str):
# Simple API key - will be handled by auth module later
self._api_key = auth
self._auth_handler = None
else:
# Auth handler (for future implementation)
self._api_key = None
self._auth_handler = auth
# Request configuration
self.timeout = timeout
self.verify_ssl = verify_ssl
self.user_agent = user_agent or f"wikijs-python-sdk/0.1.0"
# Initialize HTTP session
self._session = self._create_session()
# Endpoint handlers (will be initialized as we implement them)
# self.pages = PagesEndpoint(self)
# self.users = UsersEndpoint(self)
# self.groups = GroupsEndpoint(self)
def _create_session(self) -> requests.Session:
"""Create configured HTTP session with retry strategy.
Returns:
Configured requests session
"""
session = requests.Session()
# Configure retry strategy
retry_strategy = Retry(
total=3,
backoff_factor=1,
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=["HEAD", "GET", "OPTIONS", "POST", "PUT", "DELETE"],
)
adapter = HTTPAdapter(max_retries=retry_strategy)
session.mount("http://", adapter)
session.mount("https://", adapter)
# Set default headers
session.headers.update({
"User-Agent": self.user_agent,
"Accept": "application/json",
"Content-Type": "application/json",
})
# Set authentication headers
if self._api_key:
session.headers["Authorization"] = f"Bearer {self._api_key}"
elif self._auth_handler:
auth_headers = self._auth_handler.get_headers()
session.headers.update(auth_headers)
return session
def _request(
self,
method: str,
endpoint: str,
params: Optional[Dict[str, Any]] = None,
json_data: Optional[Dict[str, Any]] = None,
**kwargs
) -> Dict[str, Any]:
"""Make HTTP request to Wiki.js API.
Args:
method: HTTP method (GET, POST, PUT, DELETE)
endpoint: API endpoint path
params: Query parameters
json_data: JSON data for request body
**kwargs: Additional request parameters
Returns:
Parsed response data
Raises:
AuthenticationError: If authentication fails
APIError: If API returns an error
ConnectionError: If connection fails
TimeoutError: If request times out
"""
# Build full URL
url = build_api_url(self.base_url, endpoint)
# Prepare request arguments
request_kwargs = {
"timeout": self.timeout,
"verify": self.verify_ssl,
"params": params,
**kwargs
}
# Add JSON data if provided
if json_data is not None:
request_kwargs["json"] = json_data
try:
# Make request
response = self._session.request(method, url, **request_kwargs)
# Handle response
return self._handle_response(response)
except requests.exceptions.Timeout as e:
raise TimeoutError(f"Request timed out after {self.timeout} seconds") from e
except requests.exceptions.ConnectionError as e:
raise ConnectionError(f"Failed to connect to {self.base_url}") from e
except requests.exceptions.RequestException as e:
raise APIError(f"Request failed: {str(e)}") from e
def _handle_response(self, response: requests.Response) -> Dict[str, Any]:
"""Handle HTTP response and extract data.
Args:
response: HTTP response object
Returns:
Parsed response data
Raises:
AuthenticationError: If authentication fails (401)
APIError: If API returns an error
"""
# Handle authentication errors
if response.status_code == 401:
raise AuthenticationError("Authentication failed - check your API key")
# Handle other HTTP errors
if not response.ok:
error_message = extract_error_message(response)
raise create_api_error(
response.status_code,
error_message,
response
)
# Parse JSON response
try:
data = response.json()
except json.JSONDecodeError as e:
raise APIError(f"Invalid JSON response: {str(e)}") from e
# Parse Wiki.js specific response format
return parse_wiki_response(data)
def test_connection(self) -> bool:
"""Test connection to Wiki.js instance.
Returns:
True if connection successful
Raises:
ConfigurationError: If client is not properly configured
ConnectionError: If cannot connect to server
AuthenticationError: If authentication fails
"""
if not self.base_url:
raise ConfigurationError("Base URL not configured")
if not self._api_key and not self._auth_handler:
raise ConfigurationError("Authentication not configured")
try:
# Try to hit a basic endpoint (will implement with actual endpoints)
# For now, just test basic connectivity
response = self._session.get(
self.base_url,
timeout=self.timeout,
verify=self.verify_ssl
)
return True
except requests.exceptions.Timeout:
raise TimeoutError(f"Connection test timed out after {self.timeout} seconds")
except requests.exceptions.ConnectionError as e:
raise ConnectionError(f"Cannot connect to {self.base_url}: {str(e)}")
except Exception as e:
raise ConnectionError(f"Connection test failed: {str(e)}")
def __enter__(self):
"""Context manager entry."""
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Context manager exit - close session."""
self.close()
def close(self):
"""Close the HTTP session and clean up resources."""
if self._session:
self._session.close()
def __repr__(self) -> str:
"""String representation of client."""
return f"WikiJSClient(base_url='{self.base_url}')"

View File

@@ -0,0 +1,22 @@
"""API endpoints module for wikijs-python-sdk.
This module will contain endpoint handlers for different
Wiki.js API endpoints.
Future implementations:
- Pages API (CRUD operations)
- Users API (user management)
- Groups API (group management)
- Assets API (file management)
- System API (system information)
"""
# Placeholder for future endpoint implementations
# from .base import BaseEndpoint
# from .pages import PagesEndpoint
# from .users import UsersEndpoint
# from .groups import GroupsEndpoint
__all__ = [
# Will be implemented in Task 1.4
]

109
wikijs/exceptions.py Normal file
View File

@@ -0,0 +1,109 @@
"""Exception hierarchy for wikijs-python-sdk."""
from typing import Any, Dict, Optional
class WikiJSException(Exception):
"""Base exception for all SDK errors."""
def __init__(self, message: str, details: Optional[Dict[str, Any]] = None):
super().__init__(message)
self.message = message
self.details = details or {}
class ConfigurationError(WikiJSException):
"""Raised when there's an issue with SDK configuration."""
pass
class AuthenticationError(WikiJSException):
"""Raised when authentication fails."""
pass
class ValidationError(WikiJSException):
"""Raised when input validation fails."""
def __init__(self, message: str, field: Optional[str] = None, value: Any = None):
super().__init__(message)
self.field = field
self.value = value
class APIError(WikiJSException):
"""Base class for API-related errors."""
def __init__(
self,
message: str,
status_code: Optional[int] = None,
response: Optional[Any] = None,
details: Optional[Dict[str, Any]] = None
):
super().__init__(message, details)
self.status_code = status_code
self.response = response
class ClientError(APIError):
"""Raised for 4xx HTTP status codes (client errors)."""
pass
class ServerError(APIError):
"""Raised for 5xx HTTP status codes (server errors)."""
pass
class NotFoundError(ClientError):
"""Raised when a requested resource is not found (404)."""
pass
class PermissionError(ClientError):
"""Raised when access is forbidden (403)."""
pass
class RateLimitError(ClientError):
"""Raised when rate limit is exceeded (429)."""
def __init__(self, message: str, retry_after: Optional[int] = None, **kwargs):
super().__init__(message, status_code=429, **kwargs)
self.retry_after = retry_after
class ConnectionError(WikiJSException):
"""Raised when there's a connection issue."""
pass
class TimeoutError(WikiJSException):
"""Raised when a request times out."""
pass
def create_api_error(status_code: int, message: str, response: Any = None) -> APIError:
"""Create appropriate API error based on status code.
Args:
status_code: HTTP status code
message: Error message
response: Raw response object
Returns:
Appropriate APIError subclass instance
"""
if status_code == 404:
return NotFoundError(message, status_code=status_code, response=response)
elif status_code == 403:
return PermissionError(message, status_code=status_code, response=response)
elif status_code == 429:
return RateLimitError(message, status_code=status_code, response=response)
elif 400 <= status_code < 500:
return ClientError(message, status_code=status_code, response=response)
elif 500 <= status_code < 600:
return ServerError(message, status_code=status_code, response=response)
else:
return APIError(message, status_code=status_code, response=response)

11
wikijs/models/__init__.py Normal file
View File

@@ -0,0 +1,11 @@
"""Data models for wikijs-python-sdk."""
from .base import BaseModel
from .page import Page, PageCreate, PageUpdate
__all__ = [
"BaseModel",
"Page",
"PageCreate",
"PageUpdate",
]

90
wikijs/models/base.py Normal file
View File

@@ -0,0 +1,90 @@
"""Base model functionality for wikijs-python-sdk."""
from datetime import datetime
from typing import Any, Dict, Optional
from pydantic import BaseModel as PydanticBaseModel, ConfigDict
class BaseModel(PydanticBaseModel):
"""Base model with common functionality for all data models.
Provides:
- Automatic validation via Pydantic
- JSON serialization/deserialization
- Field aliases for API compatibility
- Consistent datetime handling
"""
model_config = ConfigDict(
# Allow population by field name or alias
populate_by_name=True,
# Validate assignment to attributes
validate_assignment=True,
# Use enum values instead of names
use_enum_values=True,
# Allow extra fields for forward compatibility
extra="ignore",
# Serialize datetime as ISO format
json_encoders={
datetime: lambda v: v.isoformat() if v else None
}
)
def to_dict(self, exclude_none: bool = True) -> Dict[str, Any]:
"""Convert model to dictionary.
Args:
exclude_none: Whether to exclude None values
Returns:
Dictionary representation of the model
"""
return self.model_dump(exclude_none=exclude_none, by_alias=True)
def to_json(self, exclude_none: bool = True) -> str:
"""Convert model to JSON string.
Args:
exclude_none: Whether to exclude None values
Returns:
JSON string representation of the model
"""
return self.model_dump_json(exclude_none=exclude_none, by_alias=True)
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "BaseModel":
"""Create model instance from dictionary.
Args:
data: Dictionary data
Returns:
Model instance
"""
return cls(**data)
@classmethod
def from_json(cls, json_str: str) -> "BaseModel":
"""Create model instance from JSON string.
Args:
json_str: JSON string
Returns:
Model instance
"""
return cls.model_validate_json(json_str)
class TimestampedModel(BaseModel):
"""Base model with timestamp fields."""
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
@property
def is_new(self) -> bool:
"""Check if this is a new (unsaved) model."""
return self.created_at is None

188
wikijs/models/page.py Normal file
View File

@@ -0,0 +1,188 @@
"""Page-related data models for wikijs-python-sdk."""
import re
from datetime import datetime
from typing import List, Optional
from pydantic import Field, validator
from .base import BaseModel, TimestampedModel
class Page(TimestampedModel):
"""Represents a Wiki.js page.
This model contains all the data for a wiki page including
content, metadata, and computed properties.
"""
id: int = Field(..., description="Unique page identifier")
title: str = Field(..., description="Page title")
path: str = Field(..., description="Page path/slug")
content: str = Field(..., description="Page content")
# Optional fields that may be present
description: Optional[str] = Field(None, description="Page description")
is_published: bool = Field(True, description="Whether page is published")
is_private: bool = Field(False, description="Whether page is private")
# Metadata
tags: List[str] = Field(default_factory=list, description="Page tags")
locale: str = Field("en", description="Page locale")
# Author information
author_id: Optional[int] = Field(None, alias="authorId")
author_name: Optional[str] = Field(None, alias="authorName")
author_email: Optional[str] = Field(None, alias="authorEmail")
# Editor information
editor: Optional[str] = Field(None, description="Editor used")
@validator("path")
def validate_path(cls, v):
"""Validate page path format."""
if not v:
raise ValueError("Path cannot be empty")
# Remove leading/trailing slashes and normalize
v = v.strip("/")
# Check for valid characters (letters, numbers, hyphens, underscores, slashes)
if not re.match(r"^[a-zA-Z0-9\-_/]+$", v):
raise ValueError("Path contains invalid characters")
return v
@validator("title")
def validate_title(cls, v):
"""Validate page title."""
if not v or not v.strip():
raise ValueError("Title cannot be empty")
# Limit title length
if len(v) > 255:
raise ValueError("Title cannot exceed 255 characters")
return v.strip()
@property
def word_count(self) -> int:
"""Calculate word count from content."""
if not self.content:
return 0
# Simple word count - split on whitespace
words = self.content.split()
return len(words)
@property
def reading_time(self) -> int:
"""Estimate reading time in minutes (assuming 200 words per minute)."""
return max(1, self.word_count // 200)
@property
def url_path(self) -> str:
"""Get the full URL path for this page."""
return f"/{self.path}"
def extract_headings(self) -> List[str]:
"""Extract markdown headings from content.
Returns:
List of heading text (without # markers)
"""
if not self.content:
return []
headings = []
for line in self.content.split("\n"):
line = line.strip()
if line.startswith("#"):
# Remove # markers and whitespace
heading = re.sub(r"^#+\s*", "", line).strip()
if heading:
headings.append(heading)
return headings
def has_tag(self, tag: str) -> bool:
"""Check if page has a specific tag.
Args:
tag: Tag to check for
Returns:
True if page has the tag
"""
return tag.lower() in [t.lower() for t in self.tags]
class PageCreate(BaseModel):
"""Data model for creating a new page."""
title: str = Field(..., description="Page title")
path: str = Field(..., description="Page path/slug")
content: str = Field(..., description="Page content")
# Optional fields
description: Optional[str] = Field(None, description="Page description")
is_published: bool = Field(True, description="Whether to publish immediately")
is_private: bool = Field(False, description="Whether page should be private")
tags: List[str] = Field(default_factory=list, description="Page tags")
locale: str = Field("en", description="Page locale")
editor: str = Field("markdown", description="Editor to use")
@validator("path")
def validate_path(cls, v):
"""Validate page path format."""
if not v:
raise ValueError("Path cannot be empty")
# Remove leading/trailing slashes and normalize
v = v.strip("/")
# Check for valid characters
if not re.match(r"^[a-zA-Z0-9\-_/]+$", v):
raise ValueError("Path contains invalid characters")
return v
@validator("title")
def validate_title(cls, v):
"""Validate page title."""
if not v or not v.strip():
raise ValueError("Title cannot be empty")
if len(v) > 255:
raise ValueError("Title cannot exceed 255 characters")
return v.strip()
class PageUpdate(BaseModel):
"""Data model for updating an existing page."""
# All fields optional for partial updates
title: Optional[str] = Field(None, description="Page title")
content: Optional[str] = Field(None, description="Page content")
description: Optional[str] = Field(None, description="Page description")
is_published: Optional[bool] = Field(None, description="Publication status")
is_private: Optional[bool] = Field(None, description="Privacy status")
tags: Optional[List[str]] = Field(None, description="Page tags")
@validator("title")
def validate_title(cls, v):
"""Validate page title if provided."""
if v is not None:
if not v.strip():
raise ValueError("Title cannot be empty")
if len(v) > 255:
raise ValueError("Title cannot exceed 255 characters")
return v.strip()
return v

0
wikijs/py.typed Normal file
View File

15
wikijs/utils/__init__.py Normal file
View File

@@ -0,0 +1,15 @@
"""Utility functions for wikijs-python-sdk."""
from .helpers import (
normalize_url,
sanitize_path,
validate_url,
parse_wiki_response,
)
__all__ = [
"normalize_url",
"sanitize_path",
"validate_url",
"parse_wiki_response",
]

209
wikijs/utils/helpers.py Normal file
View File

@@ -0,0 +1,209 @@
"""Helper utilities for wikijs-python-sdk."""
import re
from typing import Any, Dict, Optional
from urllib.parse import urljoin, urlparse
from ..exceptions import APIError, ValidationError
def normalize_url(base_url: str) -> str:
"""Normalize a base URL for API usage.
Args:
base_url: Base URL to normalize
Returns:
Normalized URL without trailing slash
Raises:
ValidationError: If URL is invalid
"""
if not base_url:
raise ValidationError("Base URL cannot be empty")
# Add https:// if no scheme provided
if not base_url.startswith(("http://", "https://")):
base_url = f"https://{base_url}"
# Validate URL format
if not validate_url(base_url):
raise ValidationError(f"Invalid URL format: {base_url}")
# Remove trailing slash
return base_url.rstrip("/")
def validate_url(url: str) -> bool:
"""Validate URL format.
Args:
url: URL to validate
Returns:
True if URL is valid
"""
try:
result = urlparse(url)
return all([result.scheme, result.netloc])
except Exception:
return False
def sanitize_path(path: str) -> str:
"""Sanitize a wiki page path.
Args:
path: Path to sanitize
Returns:
Sanitized path
Raises:
ValidationError: If path is invalid
"""
if not path:
raise ValidationError("Path cannot be empty")
# Remove leading/trailing slashes and whitespace
path = path.strip().strip("/")
# Replace spaces with hyphens
path = re.sub(r"\s+", "-", path)
# Remove invalid characters, keep only alphanumeric, hyphens, underscores, slashes
path = re.sub(r"[^a-zA-Z0-9\-_/]", "", path)
# Remove multiple consecutive hyphens or slashes
path = re.sub(r"[-/]+", lambda m: m.group(0)[0], path)
if not path:
raise ValidationError("Path contains no valid characters")
return path
def build_api_url(base_url: str, endpoint: str) -> str:
"""Build full API URL from base URL and endpoint.
Args:
base_url: Base URL (already normalized)
endpoint: API endpoint path
Returns:
Full API URL
"""
# Ensure endpoint starts with /
if not endpoint.startswith("/"):
endpoint = f"/{endpoint}"
# Wiki.js API is typically at /graphql, but we'll use REST-style for now
api_base = f"{base_url}/api"
return urljoin(api_base, endpoint.lstrip("/"))
def parse_wiki_response(response_data: Dict[str, Any]) -> Dict[str, Any]:
"""Parse Wiki.js API response data.
Args:
response_data: Raw response data from API
Returns:
Parsed response data
Raises:
APIError: If response indicates an error
"""
if not isinstance(response_data, dict):
return response_data
# Check for error indicators
if "error" in response_data:
error_info = response_data["error"]
if isinstance(error_info, dict):
message = error_info.get("message", "Unknown API error")
code = error_info.get("code")
else:
message = str(error_info)
code = None
raise APIError(f"API Error: {message}", details={"code": code})
# Handle GraphQL-style errors
if "errors" in response_data:
errors = response_data["errors"]
if errors:
first_error = errors[0] if isinstance(errors, list) else errors
message = first_error.get("message", "GraphQL error") if isinstance(first_error, dict) else str(first_error)
raise APIError(f"GraphQL Error: {message}", details={"errors": errors})
return response_data
def extract_error_message(response: Any) -> str:
"""Extract error message from response.
Args:
response: Response object or data
Returns:
Error message string
"""
if hasattr(response, "json"):
try:
data = response.json()
if isinstance(data, dict):
# Try common error message fields
for field in ["message", "error", "detail", "msg"]:
if field in data:
return str(data[field])
except Exception:
pass
if hasattr(response, "text"):
return response.text[:200] + "..." if len(response.text) > 200 else response.text
return str(response)
def chunk_list(items: list, chunk_size: int) -> list:
"""Split list into chunks of specified size.
Args:
items: List to chunk
chunk_size: Size of each chunk
Returns:
List of chunks
"""
if chunk_size <= 0:
raise ValueError("Chunk size must be positive")
return [items[i:i + chunk_size] for i in range(0, len(items), chunk_size)]
def safe_get(data: Dict[str, Any], key: str, default: Any = None) -> Any:
"""Safely get value from dictionary with dot notation support.
Args:
data: Dictionary to get value from
key: Key (supports dot notation like "user.name")
default: Default value if key not found
Returns:
Value or default
"""
if "." not in key:
return data.get(key, default)
keys = key.split(".")
current = data
for k in keys:
if isinstance(current, dict) and k in current:
current = current[k]
else:
return default
return current

7
wikijs/version.py Normal file
View File

@@ -0,0 +1,7 @@
"""Version information for wikijs-python-sdk."""
__version__ = "0.1.0"
__version_info__ = (0, 1, 0)
# Version history
# 0.1.0 - MVP Release: Basic Wiki.js integration with Pages API