Full Code of benborgers/potion for AI

master 68fbb1bbad58 cached
14 files
18.0 KB
5.0k tokens
1 requests
Download .txt
Repository: benborgers/potion
Branch: master
Commit: 68fbb1bbad58
Files: 14
Total size: 18.0 KB

Directory structure:
gitextract_c3udltrp/

├── .gitignore
├── LICENSE
├── README.md
├── api/
│   ├── asset.js
│   ├── html.js
│   ├── table-description.js
│   └── table.js
├── helpers/
│   └── escape.js
├── notion/
│   ├── call.js
│   ├── getAssetUrl.js
│   ├── normalizeId.js
│   └── textArrayToHtml.js
├── now.json
└── package.json

================================================
FILE CONTENTS
================================================

================================================
FILE: .gitignore
================================================
node_modules
.now
.vercel

================================================
FILE: LICENSE
================================================
MIT License

Copyright (c) 2022 Benjamin Borgers

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


================================================
FILE: README.md
================================================
# Potion

Potion is a reverse-engineered API for [Notion](https://notion.so). Write your content in Notion, and use Potion's hosted API endpoints to read your content.

**I no longer maintain Potion, since the [official Notion API](https://developers.notion.com) has been released and is more stable than Notion’s internal API, which Potion relies on.**

## Guides

I've written a couple of blog posts on my website for using this API.

* [How to use Notion as your blog's CMS](https://benborgers.com/blog/notion-blog)
* [API to read a Notion table](https://benborgers.com/blog/notion-table)
* [How to turn a Notion doc into a website](https://benborgers.com/blog/notion-to-website)

## Endpoints

All endpoints are relative to the base URL: `https://potion-api.now.sh`

Responses are cached for 10 seconds, so it'll take up to 10 seconds for changes made in Notion to show up.

*`<notion-page-id>` refers to the 32 character alphanumeric string in the URL of a Notion doc (but not a query parameter, so not the string after `?v=`).*

### /table

Lists all entries in a full-page Notion table, along with additional details about each page.

The only query parameter is `?id=<notion-page-id>`.

### /table-description

Generates HTML for the description of a table.

The only query parameter is `?id=<notion-page-id>`.

### /html

Generates HTML for a given Notion page. You can insert it as the contents of a blog post, for example.

The only query parameter is `?id=<notion-page-id>`, which can be obtained from the `/table` endpoint or just by copy-and-pasting from the URL.

## Syntax Highlighting

Potion gives you syntax highlighting of Notion code blocks for free, when using the `/html` endpoint!

### How to use syntax highlighting

You'll notice that the code block HTML that Potion returns is given CSS classes that make it compatible with [Prism.js](https://prismjs.com/).

1. Pick a theme you like from [this README](https://github.com/PrismJS/prism-themes/blob/master/README.md).
2. Select the CSS file for that theme [from this list](https://unpkg.com/browse/prism-themes@latest/themes/) and click **View Raw**.
3. Include that stylesheet in the `head` of your HTML page to activate syntax highlighting. For example:
  ```html
  <link rel="stylesheet" href="https://unpkg.com/prism-themes@1.4.0/themes/prism-ghcolors.css" />
  ```

### Language support

Potion supports syntax highlighting for most popular languages, and you can open an issue if you'd like to see a language supported that isn't currently.


## Limitations

Most, but not all, of the common Notion blocks are supported at the moment:

- [x] Text
- [x] To-do List
- [x] Heading 1
- [x] Heading 2
- [x] Heading 3
- [x] Bulleted List
- [x] Numbered List
- [ ] Toggle List
- [x] Quote
- [x] Divider
- [ ] Link to Page
- [x] Callout
- [x] Image
- [x] Embed
- [ ] Web Bookmark
- [x] Video
- [ ] Audio
- [x] Code
- [ ] File
- [x] Math Equation
- [x] Inline Equation

## Development and Deployment

This project is built to be deployed on [Vercel](https://vercel.com/home).

For local development, install [Vercel's CLI](https://vercel.com/download) and run `vercel dev`.


================================================
FILE: api/asset.js
================================================
/* Get signed asset URL for Notion S3 files */

const call = require("../notion/call")

module.exports = async (req, res) => {
  const { url, blockId } = req.query

  if(!url) {
    return res.json({
      error: "No asset URL provided."
    })
  }

  if(!blockId) {
    return res.json({
      error: "No block ID provided."
    })
  }

  const assetRes = await call("getSignedFileUrls", {
    urls: [
      {
        url,
        permissionRecord: {
          table: "block",
          id: blockId
        }
      }
    ]
  })

  
  res.status(307)
  res.setHeader("location", assetRes.signedUrls[0])
  res.end()
}

================================================
FILE: api/html.js
================================================
/* Returns reconstructed HTML for a given Notion doc */

const katex = require("katex")
const prism = require("prismjs")
require("prismjs/components/prism-markup-templating")
require("prismjs/components/prism-php")
require("prismjs/components/prism-python")
require("prismjs/components/prism-ruby")
require("prismjs/components/prism-json")
require("prismjs/components/prism-java")
require("prismjs/components/prism-yaml")
require("prismjs/components/prism-bash")

const call = require("../notion/call")
const normalizeId = require("../notion/normalizeId")
const textArrayToHtml = require("../notion/textArrayToHtml.js")

module.exports = async (req, res) => {
  const { id:queryId } = req.query
  const id = normalizeId(queryId)

  if(!id) {
    return res.json({
      error: "no Notion doc ID provided as `id` parameter"
    })
  }

  const overview = await call("syncRecordValues", {
    requests: [
      {
        id,
        table: "block",
        version: -1
      }
    ]
  })

  if(!overview.recordMap.block[id].value) {
    return res.json({
      error: "could not read Notion doc with this ID - make sure public access is enabled"
    })
  }

  const contentIds = overview.recordMap.block[id].value.content

  if(!contentIds) {
    return res.json({
      error: "this doc has no content"
    })
  }

  const contents = []
  let recordMap = {}
  let lastChunk
  let hasMorePageChunks = true

  while(hasMorePageChunks) {
    const cursor = lastChunk && lastChunk.cursor || ({ stack: [] })

    const chunk = await call("loadPageChunk", {
        pageId: id,
        limit: 100,
        cursor,
        chunkNumber: 0,
        verticalColumns: false
    })

    recordMap = { ...recordMap, ...chunk.recordMap.block }

    lastChunk = chunk

    if(chunk.cursor.stack.length === 0) hasMorePageChunks = false
  }

  contentIds.forEach(id => {
    const block = recordMap[id]
    if(block) contents.push(block.value)
  })

  const html = []

  contents.forEach(block => {
    const type = block.type

    if(["header", "sub_header", "sub_sub_header", "text"].includes(type)) {
      /* Headers (H1 - H3) and plain text */
      const el = {
        header: "h1",
        sub_header: "h2",
        sub_sub_header: "h3",
        text: "p"
      }[type]

      if(!block.properties) {
        // This is an empty text block.
        return
      }

      html.push(`<${el}>${textArrayToHtml(block.properties.title)}</${el}>`)
    } else if(["numbered_list", "bulleted_list"].includes(type)) {
      /* Numbered and bulleted lists */
      const el = {
        "numbered_list": "ol",
        "bulleted_list": "ul"
      }[type]

      html.push(`<${el}><li>${textArrayToHtml(block.properties && block.properties.title)}</li></${el}>`)
    } else if(["to_do"].includes(type)) {
      /* To do list represented by a list of checkbox inputs */
      const checked = Boolean(block.properties.checked)
      html.push(`<div class="checklist"><label><input type="checkbox" disabled${checked ? " checked" : ""}>${textArrayToHtml(block.properties.title)}</label></div>`)
    } else if(["code"].includes(type)) {
      /* Full code blocks with language */
      const language = block.properties.language[0][0].toLowerCase().replace(/ /g, "")
      const text = block.properties.title || [[""]]

      // Inject unescaped HTML if code block's language is set to LiveScript
      const showLive = language === "livescript"
      if(showLive) {
        html.push(text.map(clip => clip[0]).join("")) // Ignore styling, just take the text
      } else {
        const code = textArrayToHtml(text, { br: false, escape: false })
        let highlighted = code
        try {
          // try/catch because this fails when prism doesn't know the language
          highlighted = prism.highlight(code, prism.languages[language], language)
        } catch{}
        html.push(`<pre><code class="language-${language}">${highlighted}</code></pre>`)
      }
    } else if(["callout"].includes(type)) {
      /* Callout formatted with emoji from emojicdn.elk.sh or just image */
      const icon = block.format.page_icon
      const imageLink = icon.startsWith("http") ? `https://www.notion.so/image/${encodeURIComponent(icon)}?table=block&id=${block.id}` : `https://emojicdn.elk.sh/${icon}`
      const color = block.format.block_color.split("_")[0]
      const isBackground = block.format.block_color.split("_").length > 1
      const text = block.properties.title
      html.push(`<div class="callout${isBackground ? " background" : " color"}-${color}"><img src="${imageLink}"><p>${textArrayToHtml(text)}</p></div>`)
    } else if(["quote"].includes(type)) {
      html.push(`<blockquote>${textArrayToHtml(block.properties.title)}</blockquote>`)
    } else if(["divider"].includes(type)) {
      html.push(`<hr>`)
    } else if(["image"].includes(type)) {
      html.push(`<img src="https://www.notion.so/image/${encodeURIComponent(block.format.display_source)}?table=block&id=${block.id}">`)
    } else if(["equation"].includes(type)) {
      if(!block.properties) {
        // Equation block is empty
        return
      }
      const equation = block.properties.title[0][0]
      const equationHtml = katex.renderToString(equation, { throwOnError: false })
      html.push(`<div class="equation">${equationHtml}</div>`)
    } else if(["embed"].includes(type)) {
      html.push(`<iframe src="${block.properties.source[0][0]}"></iframe>`)
    } else if(["video"].includes(type)) {
      html.push(`<iframe src="${block.format.display_source}"></iframe>`)
    } else {
      /* Catch blocks without handler method */
      console.log(`Unhandled block type "${block.type}"`, block)
    }
  })

  // Only add Katex stylesheet if there's Katex elements.
  if(html.join("").includes(`class="katex"`)) {
    html.push(`<link rel="stylesheet" href="https://unpkg.com/katex@0.12.0/dist/katex.min.css">`)
  }

  const joinedHtml = html.join("")
  const cleanedHtml = joinedHtml
                        .replace(/<\/ol><ol>/g, "")
                        .replace(/<\/ul><ul>/g, "")
                        .replace(/<\/div><div class="checklist">/g, "")
  res.send(cleanedHtml)
}


================================================
FILE: api/table-description.js
================================================
/* Return the description of a Notion collection */

const call = require("../notion/call")
const normalizeId = require("../notion/normalizeId")
const textArrayToHtml = require("../notion/textArrayToHtml")

module.exports = async (req, res) => {
  const { id:queryId } = req.query
  const id = normalizeId(queryId)

  if(!id) {
    return res.json({
      error: "no Notion doc ID provided as `id` parameter"
    })
  }

  const {recordMap} = await call("loadPageChunk", {
    pageId: id,
    limit: 100,
    cursor: {
      stack: [
        [
          {
            table: "block",
            id: id, 
            index: 0
          }
        ]
      ]
    },
    chunkNumber: 0,
    verticalColumns: false
  });

  const collectionBlock = recordMap.block[id].value;

  if(!collectionBlock) {
    return res.json({
      error: "invalid Notion doc ID, or public access is not enabled on this doc"
    })
  }

  if(!collectionBlock.type.startsWith("collection_view")) {
    return res.json({
      error: "this Notion doc is not a collection"
    })
  }

  const descriptionArray = recordMap.collection[collectionBlock.collection_id].value.description;

  res.send(textArrayToHtml(descriptionArray))
}


================================================
FILE: api/table.js
================================================
/* Return the entries of a table in Notion */

const call = require("../notion/call")
const normalizeId = require("../notion/normalizeId")
const textArrayToHtml = require("../notion/textArrayToHtml.js")
const getAssetUrl = require("../notion/getAssetUrl")

module.exports = async (req, res) => {
  return res.send("This API has been deprecated. Please use the official Notion API (developers.notion.com) instead.")
  
  const { id:queryId } = req.query
  const id = normalizeId(queryId)

  if(!id) {
    return res.json({
      error: "no Notion doc ID provided as `id` parameter"
    })
  }

  const pageData = await call("getRecordValues", {
    requests: [
      {
        id: id,
        table: "block"
      }
    ]
  })

  if(!pageData.results[0].value) {
    return res.json({
      error: "invalid Notion doc ID, or public access is not enabled on this doc"
    })
  }

  if(!pageData.results[0].value.type.startsWith("collection_view")) {
    return res.json({
      error: "this Notion doc is not a collection"
    })
  }

  const collectionId = pageData.results[0].value.collection_id
  const collectionViewId = pageData.results[0].value.view_ids[0]


  const tableData = await call("queryCollection", {
    collectionId,
    collectionViewId,
    query: {},
    loader: {
      type: 'table',
      limit: 99999,
      userTimeZone: 'UTC',
      loadContentCover: true
    }
  })

  const subPages = tableData.result.blockIds

  const schema = tableData.recordMap.collection[collectionId].value.schema

  const output = []

  subPages.forEach(id => {
    const page = tableData.recordMap.block[id]

    const fields = {}

    for(const s in schema) {
      const schemaDefinition = schema[s]
      const type = schemaDefinition.type
      let value = page.value.properties && page.value.properties[s] && page.value.properties[s][0][0]

      if(type === "checkbox") {
        value = value === "Yes" ? true : false
      } else if(value && type === "date") {
        try {
          value = page.value.properties[s][0][1][0][1]
        } catch {
          // it seems the older Notion date format is [[ string ]]
          value = page.value.properties[s][0][0]
        }
      } else if(value && type === "text") {
        value = textArrayToHtml(page.value.properties[s])
      } else if(value && type === "file") {
        const files = page.value.properties[s].filter(f => f.length > 1)
        // some items in the files array are for some reason just [","]

        const outputFiles = []

        files.forEach(file => {
          const s3Url = file[1][0][1]
          outputFiles.push(getAssetUrl(s3Url, page.value.id))
        })

        value = outputFiles
      } else if(value && type === "multi_select") {
        value = value.split(",")
      }

      fields[schemaDefinition.name] = value || undefined
    }

    output.push({
      fields,
      id: page.value.id,
      emoji: page.value.format && page.value.format.page_icon,
      created: page.value.created_time,
      last_edited: page.value.last_edited_time
    })
  })


  return res.json(output)
}


================================================
FILE: helpers/escape.js
================================================
/* Excape HTML */

module.exports = text => text
                          .replace(/</g, "&lt;")
                          .replace(/>/g, "&gt;")

================================================
FILE: notion/call.js
================================================
/* Call a method on Notion's API */

const fetch = require("node-fetch")

module.exports = (methodName, body) => new Promise(resolve => {
  fetch(`https://www.notion.so/api/v3/${methodName}`, {
    method: "POST",
    headers: {
      "content-type": "application/json"
    },
    body: JSON.stringify(body)
  })
    .then(res => res.json())
    .then(json => resolve(json))
})

================================================
FILE: notion/getAssetUrl.js
================================================
module.exports = (url, blockId) => {
  const BASE = process.env.NODE_ENV === "development" ? "http://localhost:3000" : "https://potion-api.now.sh"

  return `${BASE}/api/asset?url=${encodeURIComponent(url)}&blockId=${blockId}`
}

================================================
FILE: notion/normalizeId.js
================================================
/* Normalize a UUID for use in Notion's API */

module.exports = (id) => {
  if(!id) return id
  if(id.length === 36) return id // Already normalized
  return `${id.substr(0, 8)}-${id.substr(8, 4)}-${id.substr(12, 4)}-${id.substr(16, 4)}-${id.substr(20)}`
}

================================================
FILE: notion/textArrayToHtml.js
================================================
/* Turns an array of text, returned by Notion's API, into HTML */

const katex = require("katex")

const escapeText = require("../helpers/escape")

module.exports = (source, options={ br: true, escape: true }) => {
  const output = []

  if(!source) return ""

  source.forEach(clip => {
    let text = options.escape ? escapeText(clip[0]) : clip[0]

    if(clip.length === 1) {
      output.push(text)
    } else {
      const modifiers = clip[1]

      modifiers.forEach(mod => {
        const modCode = mod[0]

        if(modCode === "b") {
          text = `<strong>${text}</strong>`
        } else if(modCode === "i") {
          text = `<em>${text}</em>`
        } else if(modCode === "a") {
          text = `<a href="${mod[1]}">${text}</a>`
        } else if(modCode === "s") {
          text = `<strike>${text}</strike>`
        } else if(modCode === "h") {
          const color = mod[1].split("_")[0]
          const isBackground = mod[1].split("_").length > 1
          text = `<span class="${isBackground ? "background" : "color"}-${color}">${text}</span>`
        } else if(modCode === "c") {
          text = `<code>${text}</code>`
        } else if(modCode === "e") {
          text = `<span class="equation">${katex.renderToString(mod[1], { throwOnError: false })}</span>`
        } else {
          console.error("Unhandled modification in textArrayToHtml()", mod)
        }
      })

      output.push(text)
    }

  })
  
  return options.br ? output.join("").replace(/\n/g, "<br>") : output.join("")
}

================================================
FILE: now.json
================================================
{
  "redirects": [
    { "source": "/", "destination": "https://github.com/benborgers/potion" }
  ],
  "rewrites": [
    { "source": "/(.*)", "destination": "/api/$1" }
  ],
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        {
          "key": "access-control-allow-origin",
          "value": "*"
        },
        {
          "key": "Cache-Control",
          "value": "s-maxage=10"
        }
      ]
    }
  ]
}

================================================
FILE: package.json
================================================
{
  "name": "potion",
  "author": "Ben Borgers <borgersbenjamin@gmail.com>",
  "license": "ISC",
  "dependencies": {
    "katex": "^0.12.0",
    "node-fetch": "^2.6.1",
    "prismjs": "^1.25.0"
  }
}
Download .txt
gitextract_c3udltrp/

├── .gitignore
├── LICENSE
├── README.md
├── api/
│   ├── asset.js
│   ├── html.js
│   ├── table-description.js
│   └── table.js
├── helpers/
│   └── escape.js
├── notion/
│   ├── call.js
│   ├── getAssetUrl.js
│   ├── normalizeId.js
│   └── textArrayToHtml.js
├── now.json
└── package.json
Condensed preview — 14 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (20K chars).
[
  {
    "path": ".gitignore",
    "chars": 25,
    "preview": "node_modules\n.now\n.vercel"
  },
  {
    "path": "LICENSE",
    "chars": 1073,
    "preview": "MIT License\n\nCopyright (c) 2022 Benjamin Borgers\n\nPermission is hereby granted, free of charge, to any person obtaining "
  },
  {
    "path": "README.md",
    "chars": 3147,
    "preview": "# Potion\n\nPotion is a reverse-engineered API for [Notion](https://notion.so). Write your content in Notion, and use Poti"
  },
  {
    "path": "api/asset.js",
    "chars": 616,
    "preview": "/* Get signed asset URL for Notion S3 files */\n\nconst call = require(\"../notion/call\")\n\nmodule.exports = async (req, res"
  },
  {
    "path": "api/html.js",
    "chars": 6160,
    "preview": "/* Returns reconstructed HTML for a given Notion doc */\n\nconst katex = require(\"katex\")\nconst prism = require(\"prismjs\")"
  },
  {
    "path": "api/table-description.js",
    "chars": 1204,
    "preview": "/* Return the description of a Notion collection */\n\nconst call = require(\"../notion/call\")\nconst normalizeId = require("
  },
  {
    "path": "api/table.js",
    "chars": 3087,
    "preview": "/* Return the entries of a table in Notion */\n\nconst call = require(\"../notion/call\")\nconst normalizeId = require(\"../no"
  },
  {
    "path": "helpers/escape.js",
    "chars": 146,
    "preview": "/* Excape HTML */\n\nmodule.exports = text => text\n                          .replace(/</g, \"&lt;\")\n                      "
  },
  {
    "path": "notion/call.js",
    "chars": 377,
    "preview": "/* Call a method on Notion's API */\n\nconst fetch = require(\"node-fetch\")\n\nmodule.exports = (methodName, body) => new Pro"
  },
  {
    "path": "notion/getAssetUrl.js",
    "chars": 228,
    "preview": "module.exports = (url, blockId) => {\n  const BASE = process.env.NODE_ENV === \"development\" ? \"http://localhost:3000\" : \""
  },
  {
    "path": "notion/normalizeId.js",
    "chars": 257,
    "preview": "/* Normalize a UUID for use in Notion's API */\n\nmodule.exports = (id) => {\n  if(!id) return id\n  if(id.length === 36) re"
  },
  {
    "path": "notion/textArrayToHtml.js",
    "chars": 1522,
    "preview": "/* Turns an array of text, returned by Notion's API, into HTML */\n\nconst katex = require(\"katex\")\n\nconst escapeText = re"
  },
  {
    "path": "now.json",
    "chars": 437,
    "preview": "{\n  \"redirects\": [\n    { \"source\": \"/\", \"destination\": \"https://github.com/benborgers/potion\" }\n  ],\n  \"rewrites\": [\n   "
  },
  {
    "path": "package.json",
    "chars": 200,
    "preview": "{\n  \"name\": \"potion\",\n  \"author\": \"Ben Borgers <borgersbenjamin@gmail.com>\",\n  \"license\": \"ISC\",\n  \"dependencies\": {\n   "
  }
]

About this extraction

This page contains the full source code of the benborgers/potion GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 14 files (18.0 KB), approximately 5.0k tokens. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!