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