2023-05-01

Publish markdown with images to Hashnode via API

To establish myself as a proper geek, my first blog post is about the blog stack itself.

There are resources on how to publish posts to Hashnode via API, but I haven’t found any with automatic upload of images.

Demo:

demo

Approach

Code:

Full source code is available on GitHub, following is just a pseudo-code.

// To obtain JWT
// 1. Open https://hashnode.com
// 2. Open DevTools
// 3. Go to Application tab
// 4. Go to Cookies
// 5. Copy value of "jwt" cookie (245 characters)
const JWT = process.env.HASHNODE_JWT;

// To obtain Publication ID
// Go to https://hashnode.com/settings/blogs
// Click on "Dashboard" button of the blog you want to upload to
// Copy ID from the URL, e.g. https://hashnode.com/<id>/dashboard
const PUBLICATION_ID = process.env.HASHNODE_PUBLICATION_ID;

// To obtain Hashnode API token
// 1. Open https://hashnode.com/settings/developer
// 2. Click on "Generate New Token" button or use the existing one
const HASHNODE_TOKEN = process.env.HASHNODE_TOKEN;

if (!JWT || !PUBLICATION_ID || !HASHNODE_TOKEN) {
  console.error("Please fill in the required environment variables")
  throw new Error("Please fill in the required environment variables")
}

const docPath = process.argv[2] // e.g. /path/to/markdown.md
const rootDir = process.argv[3] ?? '' // e.g. /users/<username>/documents/blog – important when assets paths are absolute within rootDir, as Nota app does

if (!docPath) throw new Error("No document path provided")

const docDirname = path.dirname(docPath) // e.g. /users/<username>/documents/blog/2022
const docBasename = path.basename(docPath, ".md") // e.g. My Blog Post

const docContent = fs.readFileSync(docPath, "utf-8")
let docContentModified = docContent // starts as a copy of a the original, will be modified in-place

const imagePaths = getImagesFromMarkdownDoc(docPath)
const imageUrls = [] // will be populated with URLs of uploaded images

for (const imagePath of imagePaths) {
  const imageType = path.extname(imagePath).slice(1) // e.g. `png` or `jpg`
  const imageFullPath = imagePath.startsWith(`/`)
    ? path.join(rootDir, imagePath) // Some apps, like Nota, use absolute paths within rootDir, like `/assets/image.png`
    : path.join(docDirname, imagePath) // Other apps, like Typora, use relative paths, like `./assets/image.png`

  // 1. Get info from Hashnode about Amazon S3 bucket where to upload the image
  const metaRes = await fetch(`https://hashnode.com/api/upload-image?imageType=${imageType}`, {
    "headers": {
      cookie: Object.entries({
        jwt: JWT,
        // Following seems to be important, but it's really not
        // 'hn-cookie-username': '...',
        // 'hn-user': '...',
      }).map(([key, value]) => `${key}=${value}`).join("; "),
    },
  });

  const {
    url, // https://s3.amazonaws.com/cloudmate-test
    fields // { key, bucket, X-Amz-Algorithm, X-Amz-Credential, X-Amz-Date, ... }
  } = await metaRes.json()

  // 2. Upload image to provided Amazon S3 bucket
  const formData = new FormData();
  for (const [key, value] of Object.entries(fields)) formData.append(key, value)
  formData.append("file", fileFromSync(imageFullPath))
  const uploadRes = await fetch(url, {
    method: "POST",
    // Weirdly, correctly setting content-type to multipart/form-data will case `The body of your POST request is not well-formed multipart/form-data.`
    // headers: { "content-type": "multipart/form-data;" },
    body: formData
  })

  if (uploadRes.status !== 204) {
    console.log("Upload failed!")
    console.log(await uploadRes.text())
    continue;
  }

  const imageCdnUrl = `https://cdn.hashnode.com/${fields.key}`;
  console.log(`Upload success! ${imageCdnUrl}`)
  imageUrls.push(imageCdnUrl)

  // 3. Replace the local image path with the CDN URL
  docContentModified = docContentModified.replace(imagePath, imageCdnUrl)
}

// Save the modified Markdown file, uncomment for debugging purposes
// fs.writeFileSync(docPath + `.modified.md`, docContentModified, "utf-8")

// 4. Publish modified Markdown file to Hashnode
const publishRes = await fetch('https://api.hashnode.com', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    Authorization: HASHNODE_TOKEN,
  },
  body: JSON.stringify({
    // TODO: Create as Draft by default
    query: `
      mutation createPublicationStory($input: CreateStoryInput!, $publicationId: String!) {
        createPublicationStory(input: $input, publicationId: $publicationId) {
          success
          post {
            cuid,
            slug,
            title,
            dateAdded,
            publication {
             domain
            }
          }
        }
      }
    `,
    variables: {
      publicationId: PUBLICATION_ID,
      input: {
        title: `${docBasename}`,
        contentMarkdown: docContentModified,
        // Available tags: https://github.com/Hashnode/support/blob/main/misc/tags.json
        tags: [],
        coverImageURL: imageUrls[0], // first image as Cover - not great, not terrible
      },
    },
  }),
}).then(res => res.json())

if (!publishRes?.data?.createPublicationStory?.success) {
  console.log("Publish failed!")
  console.log(publishRes)
  process.exit(1)
}

// 5. Open the published post in the browser
const url = `https://hashnode.com/edit/${publishRes.data.createPublicationStory.post.cuid}`
console.log(`Published to ${url}`)
execSync(`open ${url}`)


// TODO: Use proper parser, like markdown-it or remark
function getImagesFromMarkdownDoc(filePath) {
  const doc = fs.readFileSync(filePath, "utf-8")
  const images = doc.matchAll(/!\[(.*)]\((.*)\)/g) // capture both image title and path, for future
  return [...images].map(match => match[2]) // but return only the paths for now
}