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
JavaScript
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;
}