import * as cheerio from 'cheerio';
const BASE_URL = 'https://typst.app';
const UNIVERSE_URL = `${BASE_URL}/universe`;
export interface TypstPackage {
name: string;
version: string;
description: string;
url: string;
}
export interface PackageDetails {
name: string;
version: string;
description: string;
fullDescription: string;
authors: string[];
categories: string[];
repository?: string;
homepage?: string;
importCode: string;
versionHistory: string[];
url: string;
}
export interface SearchOptions {
query?: string;
kind?: 'packages' | 'templates';
category?: string;
limit?: number;
}
/**
* Fetches HTML content from a URL
*/
async function fetchPage(url: string): Promise<string> {
const response = await fetch(url, {
headers: {
'User-Agent': 'Typst-Universe-MCP-Server/1.0',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
},
});
if (!response.ok) {
throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
}
return response.text();
}
/**
* Searches for packages in Typst Universe
*/
export async function searchPackages(options: SearchOptions = {}): Promise<TypstPackage[]> {
const { query, kind = 'packages', category, limit = 50 } = options;
// Build the search URL
const searchParams = new URLSearchParams();
searchParams.set('kind', kind);
if (query) {
searchParams.set('q', query);
}
if (category) {
searchParams.set('category', category);
}
const searchUrl = `${UNIVERSE_URL}/search/?${searchParams.toString()}`;
const html = await fetchPage(searchUrl);
const $ = cheerio.load(html);
const packages: TypstPackage[] = [];
// Parse the package list from the page
// The packages are typically listed as links with package info
$('a[href^="/universe/package/"]').each((_, element) => {
const $el = $(element);
const href = $el.attr('href');
if (!href) return;
// Extract text content - name, version, and description are typically in the link text
const text = $el.text().trim();
// Try to parse the package info from the text
// Format is typically: "name0.0.0Description text here"
const match = text.match(/^([a-z0-9_-]+)(\d+\.\d+\.\d+)(.*)$/i);
if (match) {
const [, name, version, description] = match;
packages.push({
name: name.trim(),
version: version.trim(),
description: description.trim(),
url: `${BASE_URL}${href}`,
});
}
});
// Remove duplicates (same package might appear multiple times)
const uniquePackages = packages.filter((pkg, index, self) =>
index === self.findIndex(p => p.name === pkg.name)
);
return uniquePackages.slice(0, limit);
}
/**
* Gets detailed information about a specific package
*/
export async function getPackageDetails(packageName: string): Promise<PackageDetails | null> {
const packageUrl = `${UNIVERSE_URL}/package/${packageName}`;
try {
const html = await fetchPage(packageUrl);
const $ = cheerio.load(html);
// Extract package name and version from the header
const headerText = $('h1').first().text().trim();
// Get the description from meta tag or first paragraph
const metaDescription = $('meta[name="description"]').attr('content') ||
$('meta[property="og:description"]').attr('content') || '';
// Extract the full description from the main content
const mainContent = $('main').text().trim();
// Find version in the page
const versionMatch = mainContent.match(/(\d+\.\d+\.\d+)/);
const version = versionMatch ? versionMatch[1] : 'unknown';
// Extract authors
const authors: string[] = [];
$('a[href*="author"]').each((_, el) => {
const authorName = $(el).text().trim();
if (authorName && !authors.includes(authorName)) {
authors.push(authorName);
}
});
// Extract categories
const categories: string[] = [];
$('a[href*="category="]').each((_, el) => {
const category = $(el).text().trim();
if (category && !categories.includes(category)) {
categories.push(category);
}
});
// Extract repository link
let repository: string | undefined;
const githubLink = $('a[href*="github.com"]').first().attr('href');
if (githubLink && !githubLink.includes('typst/packages')) {
repository = githubLink;
}
// Extract homepage
let homepage: string | undefined;
$('a').each((_, el) => {
const href = $(el).attr('href');
const text = $(el).text().toLowerCase();
if (href && (text.includes('.io') || text.includes('.com') || text.includes('homepage')) &&
!href.includes('github.com') && !href.includes('typst.app')) {
homepage = href;
}
});
// Build import code
const importCode = `#import "@preview/${packageName}:${version}"`;
// Extract version history
const versionHistory: string[] = [];
$(`a[href^="/universe/package/${packageName}/"]`).each((_, el) => {
const versionText = $(el).text().trim();
if (versionText.match(/^\d+\.\d+\.\d+$/)) {
versionHistory.push(versionText);
}
});
return {
name: packageName,
version,
description: metaDescription,
fullDescription: mainContent.substring(0, 2000), // Limit full description length
authors,
categories,
repository,
homepage,
importCode,
versionHistory,
url: packageUrl,
};
} catch (error) {
console.error(`Failed to fetch package details for ${packageName}:`, error);
return null;
}
}
/**
* Gets all available categories
*/
export async function getCategories(): Promise<string[]> {
const html = await fetchPage(UNIVERSE_URL);
const $ = cheerio.load(html);
const categories: string[] = [];
$('a[href*="category="]').each((_, el) => {
const href = $(el).attr('href');
if (href) {
const match = href.match(/category=([^&]+)/);
if (match) {
const category = decodeURIComponent(match[1]);
if (!categories.includes(category)) {
categories.push(category);
}
}
}
});
// Also try to find categories in the categories section
$('a[href*="/search/?category="]').each((_, el) => {
const text = $(el).text().trim();
if (text && !categories.includes(text)) {
categories.push(text);
}
});
return categories.sort();
}
/**
* Gets featured/popular packages
*/
export async function getFeaturedPackages(): Promise<TypstPackage[]> {
const html = await fetchPage(UNIVERSE_URL);
const $ = cheerio.load(html);
const packages: TypstPackage[] = [];
// Featured packages are typically at the top of the main page
$('a[href^="/universe/package/"]').slice(0, 20).each((_, element) => {
const $el = $(element);
const href = $el.attr('href');
if (!href) return;
const text = $el.text().trim();
const match = text.match(/^([a-z0-9_-]+)(\d+\.\d+\.\d+)(.*)$/i);
if (match) {
const [, name, version, description] = match;
packages.push({
name: name.trim(),
version: version.trim(),
description: description.trim(),
url: `${BASE_URL}${href}`,
});
}
});
// Remove duplicates
return packages.filter((pkg, index, self) =>
index === self.findIndex(p => p.name === pkg.name)
);
}