UNPKG

astro-loader-hashnode

Version:

Astro content loader for seamlessly integrating Hashnode blog posts into your Astro website using the Content Layer API

245 lines (244 loc) 8.09 kB
import { createHashnodeClient, } from '../api/client.js'; /** * Digest calculation for content changes */ export function calculateDigest(content) { const str = typeof content === 'string' ? content : JSON.stringify(content); let hash = 0; for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); hash = (hash << 5) - hash + char; hash = hash & hash; // Convert to 32-bit integer } return Math.abs(hash).toString(36); } /** * Enhanced error class for loader operations */ export class LoaderError extends Error { code; details; constructor(message, code, details) { super(message); this.code = code; this.details = details; this.name = 'LoaderError'; } } /** * Abstract base class for all Hashnode loaders * * Provides common functionality like: * - Client initialization * - Error handling * - Caching with digests * - Data validation * - Astro loader interface */ export class BaseHashnodeLoader { client; config; constructor(config) { this.config = config; // Create Hashnode client with user options const clientOptions = { publicationHost: config.publicationHost, token: config.token, timeout: config.timeout, cache: config.cache !== false, cacheTTL: config.cacheTTL, }; this.client = createHashnodeClient(clientOptions); } /** * Generate unique ID for an item * Can be overridden by specific loaders */ generateId(item) { const obj = item; return obj.id || obj.cuid || obj.slug; } /** * Validate transformed data against schema */ validateData(data) { try { const validatedData = this.config.schema.parse(data); return { success: true, data: validatedData }; } catch (error) { const zodError = error; return { success: false, error: new LoaderError(`Data validation failed: ${zodError.message}`, 'VALIDATION_ERROR', { errors: zodError.errors }), }; } } /** * Safe data fetching with error handling */ async safeFetch() { try { const data = await this.fetchData(); return { success: true, data }; } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; return { success: false, error: new LoaderError(`Failed to fetch data: ${message}`, 'FETCH_ERROR', { originalError: error }), }; } } /** * Process a single item with validation and transformation */ async processItem(item) { try { // Transform the item const transformed = this.transformItem(item); // Validate against schema const validation = this.validateData(transformed); if (!validation.success) { return validation; } return { success: true, data: { id: this.generateId(item), ...validation.data, }, }; } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; return { success: false, error: new LoaderError(`Failed to process item: ${message}`, 'PROCESS_ERROR', { originalError: error }), }; } } /** * Main load method - implements Astro Loader interface */ async load(context) { const { store, logger, parseData, generateDigest } = context; logger.info(`Loading ${this.config.collection} from Hashnode...`); try { // Fetch data from API const fetchResult = await this.safeFetch(); if (!fetchResult.success) { logger.error(`Failed to fetch ${this.config.collection}: ${fetchResult.error?.message}`); return; } const items = fetchResult.data || []; logger.info(`Fetched ${items.length} items from Hashnode`); let processedCount = 0; let skippedCount = 0; let errorCount = 0; // Process each item for (const item of items) { const result = await this.processItem(item); if (!result.success) { logger.warn(`Skipping item due to error: ${result.error?.message}`); errorCount++; continue; } const processedItem = result.data; const itemId = processedItem.id; // Calculate content digest for change detection // Prefer Astro provided generateDigest if available for consistency const digest = generateDigest ? generateDigest(processedItem) : calculateDigest(processedItem); const processedWithContent = processedItem; const stored = store.set({ id: itemId, data: await parseData({ id: itemId, data: processedItem, }), digest, // Provide rendered HTML so users can leverage render(entry) rendered: { html: processedWithContent.content?.html || '', metadata: {}, }, }); if (stored) { processedCount++; } else { skippedCount++; } } logger.info(`${this.config.collection} loading complete: ` + `${processedCount} processed, ${skippedCount} skipped, ${errorCount} errors`); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; logger.error(`Fatal error loading ${this.config.collection}: ${message}`); throw new LoaderError(`Loader failed: ${message}`, 'LOADER_ERROR', { originalError: error, }); } } /** * Create an Astro Loader from this instance */ createLoader() { return { name: `hashnode-${this.config.collection}`, // Expose internal schema so users get types if they don't provide one; user schema will override. schema: () => this.config.schema, load: (context) => this.load(context), }; } /** * Get client instance (for advanced usage) */ getClient() { return this.client; } /** * Clear client cache */ clearCache() { this.client.clearCache(); } } /** * Utility function to create pagination handler */ export async function* paginateResults(fetchPage, maxItems) { let cursor; let totalFetched = 0; do { const result = await fetchPage(cursor); const { items, pageInfo } = result; if (items.length === 0) break; // Apply maxItems limit if specified let itemsToYield = items; if (maxItems && totalFetched + items.length > maxItems) { itemsToYield = items.slice(0, maxItems - totalFetched); } yield itemsToYield; totalFetched += itemsToYield.length; // Check if we should continue if (!pageInfo.hasNextPage || (maxItems && totalFetched >= maxItems)) { break; } cursor = pageInfo.endCursor; } while (true); } /** * Utility function to flatten paginated results */ export async function flattenPaginatedResults(paginatedGenerator) { const allItems = []; for await (const batch of paginatedGenerator) { allItems.push(...batch); } return allItems; }