UNPKG

astro-loader-hashnode

Version:

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

523 lines (522 loc) 13.4 kB
import { buildDynamicPostsQuery, searchPostsQuery, getUserDraftsQuery, getDraftByIdQuery, } from '../queries/index.js'; /** * Simple in-memory cache for GraphQL requests */ class RequestCache { cache = new Map(); get(key) { const entry = this.cache.get(key); if (!entry) return null; if (Date.now() - entry.timestamp > entry.ttl * 1000) { this.cache.delete(key); return null; } return entry.data; } set(key, data, ttl) { this.cache.set(key, { data, timestamp: Date.now(), ttl, }); } clear() { this.cache.clear(); } } /** * Hashnode API Client * * Provides a clean, typed wrapper around the Hashnode GraphQL API */ export class HashnodeClient { endpoint; publicationHost; token; timeout; cache; cacheTTL; constructor(options) { this.endpoint = options.endpoint || 'https://gql.hashnode.com/'; this.publicationHost = options.publicationHost; this.token = options.token; this.timeout = options.timeout || 30000; this.cacheTTL = options.cacheTTL || 300; // 5 minutes default if (options.cache !== false) { this.cache = new RequestCache(); } } /** * Execute a GraphQL query */ async query(query, variables = {}) { const cacheKey = this.cache ? this.getCacheKey(query, variables) : null; // Check cache first if (cacheKey && this.cache) { const cached = this.cache.get(cacheKey); if (cached) { return cached; } } const headers = { 'Content-Type': 'application/json', 'User-Agent': 'astro-loader-hashnode', }; // Add authorization header if token is provided if (this.token) { headers['Authorization'] = this.token; } const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.timeout); try { const response = await fetch(this.endpoint, { method: 'POST', headers, body: JSON.stringify({ query, variables, }), signal: controller.signal, }); clearTimeout(timeoutId); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const result = await response.json(); if (result.errors && result.errors.length > 0) { const errorMessages = result.errors.map(err => err.message).join(', '); throw new Error(`GraphQL errors: ${errorMessages}`); } if (!result.data) { throw new Error('No data returned from GraphQL query'); } // Cache successful results if (cacheKey && this.cache) { this.cache.set(cacheKey, result.data, this.cacheTTL); } return result.data; } catch (error) { clearTimeout(timeoutId); if (error instanceof Error && error.name === 'AbortError') { throw new Error(`Request timeout after ${this.timeout}ms`); } throw error; } } /** * Get posts from the publication */ async getPosts(options = {}) { const { first = 20, after, includeComments = false, includeCoAuthors = false, includeTableOfContents = false, } = options; const query = buildDynamicPostsQuery({ includeComments, includeCoAuthors, includeTableOfContents, includePublicationMeta: false, }); return this.query(query, { host: this.publicationHost, first, after, }); } /** * Get a single post by slug */ async getPost(slug, options = {}) { const query = ` query GetSinglePost($host: String!, $slug: String!) { publication(host: $host) { post(slug: $slug) { id cuid title subtitle brief slug url content { html markdown } coverImage { url attribution isPortrait isAttributionHidden } publishedAt updatedAt readTimeInMinutes views reactionCount responseCount replyCount hasLatexInPost author { id name username profilePicture bio { html text } socialMediaLinks { website github twitter linkedin } followersCount } tags { id name slug } seo { title description } ogMetaData { image } series { id name slug } preferences { disableComments stickCoverToBottom } ${options.includeCoAuthors ? ` coAuthors { id name username profilePicture bio { html } } ` : ''} ${options.includeComments ? ` comments(first: 25) { totalDocuments edges { node { id dateAdded totalReactions content { html markdown } author { id name username profilePicture } replies(first: 10) { edges { node { id dateAdded content { html markdown } author { id name username profilePicture } } } } } } } ` : ''} } } } `; const result = await this.query(query, { host: this.publicationHost, slug, }); return result.publication.post; } /** * Search posts in the publication */ async searchPosts(searchTerm, options = {}) { const { first = 20, after } = options; const query = searchPostsQuery(); return this.query(query, { first, after, filter: { query: searchTerm, publicationId: this.publicationHost, }, }); } /** * Get publication information */ async getPublication() { const query = ` query GetPublication($host: String!) { publication(host: $host) { id title displayTitle url urlPattern about { html text } author { id name username profilePicture bio { html text } socialMediaLinks { website github twitter linkedin } followersCount } favicon headerColor metaTags descriptionSEO isTeam followersCount preferences { layout logo disableFooterBranding enabledPages { newsletter members } darkMode { enabled logo } } features { newsletter { isEnabled } readTime { isEnabled } textSelectionSharer { isEnabled } audioBlog { isEnabled voiceType } customCSS { isEnabled } } links { twitter instagram github website hashnode youtube linkedin mastodon } integrations { umamiWebsiteUUID gaTrackingID fbPixelID hotjarSiteID matomoURL matomoSiteID fathomSiteID gTagManagerID fathomCustomDomain fathomCustomDomainEnabled plausibleAnalyticsEnabled koalaPublicKey msClarityID } ogMetaData { image } } } `; const result = await this.query(query, { host: this.publicationHost, }); return result.publication; } /** * Get posts by tag */ async getPostsByTag(tagSlug, options = {}) { const { first = 20, after } = options; const query = ` query GetPostsByTag($host: String!, $tagSlug: String!, $first: Int!, $after: String) { publication(host: $host) { id title url posts(first: $first, after: $after, filter: { tagSlugs: [$tagSlug] }) { pageInfo { hasNextPage endCursor } edges { node { id cuid title subtitle brief slug url content { html } coverImage { url attribution isPortrait isAttributionHidden } publishedAt updatedAt readTimeInMinutes views reactionCount responseCount replyCount author { id name username profilePicture bio { html text } followersCount } tags { id name slug } seo { title description } ogMetaData { image } series { id name slug } } cursor } } } } `; return this.query(query, { host: this.publicationHost, tagSlug, first, after, }); } /** * Get draft posts (requires authentication) */ async getDrafts(options = {}) { if (!this.token) { throw new Error('Authentication token required for accessing drafts'); } const { first = 20 } = options; const query = getUserDraftsQuery(); return this.query(query, { first }); } /** * Get a specific draft by ID (requires authentication) */ async getDraft(id) { if (!this.token) { throw new Error('Authentication token required for accessing drafts'); } const query = getDraftByIdQuery(); const result = await this.query(query, { id, }); return result.draft; } /** * Clear the request cache */ clearCache() { if (this.cache) { this.cache.clear(); } } /** * Generate cache key for request */ getCacheKey(query, variables) { const hash = this.simpleHash(query + JSON.stringify(variables)); return `${this.publicationHost}:${hash}`; } /** * Simple hash function for cache keys */ simpleHash(str) { 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); } } /** * Create a new Hashnode API client */ export function createHashnodeClient(options) { return new HashnodeClient(options); }