[
  {
    "path": ".gitignore",
    "content": "node_modules\n.now\n.vercel"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2022 Benjamin Borgers\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# Potion\n\nPotion 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.\n\n**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.**\n\n## Guides\n\nI've written a couple of blog posts on my website for using this API.\n\n* [How to use Notion as your blog's CMS](https://benborgers.com/blog/notion-blog)\n* [API to read a Notion table](https://benborgers.com/blog/notion-table)\n* [How to turn a Notion doc into a website](https://benborgers.com/blog/notion-to-website)\n\n## Endpoints\n\nAll endpoints are relative to the base URL: `https://potion-api.now.sh`\n\nResponses are cached for 10 seconds, so it'll take up to 10 seconds for changes made in Notion to show up.\n\n*`<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=`).*\n\n### /table\n\nLists all entries in a full-page Notion table, along with additional details about each page.\n\nThe only query parameter is `?id=<notion-page-id>`.\n\n### /table-description\n\nGenerates HTML for the description of a table.\n\nThe only query parameter is `?id=<notion-page-id>`.\n\n### /html\n\nGenerates HTML for a given Notion page. You can insert it as the contents of a blog post, for example.\n\nThe 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.\n\n## Syntax Highlighting\n\nPotion gives you syntax highlighting of Notion code blocks for free, when using the `/html` endpoint!\n\n### How to use syntax highlighting\n\nYou'll notice that the code block HTML that Potion returns is given CSS classes that make it compatible with [Prism.js](https://prismjs.com/).\n\n1. Pick a theme you like from [this README](https://github.com/PrismJS/prism-themes/blob/master/README.md).\n2. Select the CSS file for that theme [from this list](https://unpkg.com/browse/prism-themes@latest/themes/) and click **View Raw**.\n3. Include that stylesheet in the `head` of your HTML page to activate syntax highlighting. For example:\n  ```html\n  <link rel=\"stylesheet\" href=\"https://unpkg.com/prism-themes@1.4.0/themes/prism-ghcolors.css\" />\n  ```\n\n### Language support\n\nPotion 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.\n\n\n## Limitations\n\nMost, but not all, of the common Notion blocks are supported at the moment:\n\n- [x] Text\n- [x] To-do List\n- [x] Heading 1\n- [x] Heading 2\n- [x] Heading 3\n- [x] Bulleted List\n- [x] Numbered List\n- [ ] Toggle List\n- [x] Quote\n- [x] Divider\n- [ ] Link to Page\n- [x] Callout\n- [x] Image\n- [x] Embed\n- [ ] Web Bookmark\n- [x] Video\n- [ ] Audio\n- [x] Code\n- [ ] File\n- [x] Math Equation\n- [x] Inline Equation\n\n## Development and Deployment\n\nThis project is built to be deployed on [Vercel](https://vercel.com/home).\n\nFor local development, install [Vercel's CLI](https://vercel.com/download) and run `vercel dev`.\n"
  },
  {
    "path": "api/asset.js",
    "content": "/* Get signed asset URL for Notion S3 files */\n\nconst call = require(\"../notion/call\")\n\nmodule.exports = async (req, res) => {\n  const { url, blockId } = req.query\n\n  if(!url) {\n    return res.json({\n      error: \"No asset URL provided.\"\n    })\n  }\n\n  if(!blockId) {\n    return res.json({\n      error: \"No block ID provided.\"\n    })\n  }\n\n  const assetRes = await call(\"getSignedFileUrls\", {\n    urls: [\n      {\n        url,\n        permissionRecord: {\n          table: \"block\",\n          id: blockId\n        }\n      }\n    ]\n  })\n\n  \n  res.status(307)\n  res.setHeader(\"location\", assetRes.signedUrls[0])\n  res.end()\n}"
  },
  {
    "path": "api/html.js",
    "content": "/* Returns reconstructed HTML for a given Notion doc */\n\nconst katex = require(\"katex\")\nconst prism = require(\"prismjs\")\nrequire(\"prismjs/components/prism-markup-templating\")\nrequire(\"prismjs/components/prism-php\")\nrequire(\"prismjs/components/prism-python\")\nrequire(\"prismjs/components/prism-ruby\")\nrequire(\"prismjs/components/prism-json\")\nrequire(\"prismjs/components/prism-java\")\nrequire(\"prismjs/components/prism-yaml\")\nrequire(\"prismjs/components/prism-bash\")\n\nconst call = require(\"../notion/call\")\nconst normalizeId = require(\"../notion/normalizeId\")\nconst textArrayToHtml = require(\"../notion/textArrayToHtml.js\")\n\nmodule.exports = async (req, res) => {\n  const { id:queryId } = req.query\n  const id = normalizeId(queryId)\n\n  if(!id) {\n    return res.json({\n      error: \"no Notion doc ID provided as `id` parameter\"\n    })\n  }\n\n  const overview = await call(\"syncRecordValues\", {\n    requests: [\n      {\n        id,\n        table: \"block\",\n        version: -1\n      }\n    ]\n  })\n\n  if(!overview.recordMap.block[id].value) {\n    return res.json({\n      error: \"could not read Notion doc with this ID - make sure public access is enabled\"\n    })\n  }\n\n  const contentIds = overview.recordMap.block[id].value.content\n\n  if(!contentIds) {\n    return res.json({\n      error: \"this doc has no content\"\n    })\n  }\n\n  const contents = []\n  let recordMap = {}\n  let lastChunk\n  let hasMorePageChunks = true\n\n  while(hasMorePageChunks) {\n    const cursor = lastChunk && lastChunk.cursor || ({ stack: [] })\n\n    const chunk = await call(\"loadPageChunk\", {\n        pageId: id,\n        limit: 100,\n        cursor,\n        chunkNumber: 0,\n        verticalColumns: false\n    })\n\n    recordMap = { ...recordMap, ...chunk.recordMap.block }\n\n    lastChunk = chunk\n\n    if(chunk.cursor.stack.length === 0) hasMorePageChunks = false\n  }\n\n  contentIds.forEach(id => {\n    const block = recordMap[id]\n    if(block) contents.push(block.value)\n  })\n\n  const html = []\n\n  contents.forEach(block => {\n    const type = block.type\n\n    if([\"header\", \"sub_header\", \"sub_sub_header\", \"text\"].includes(type)) {\n      /* Headers (H1 - H3) and plain text */\n      const el = {\n        header: \"h1\",\n        sub_header: \"h2\",\n        sub_sub_header: \"h3\",\n        text: \"p\"\n      }[type]\n\n      if(!block.properties) {\n        // This is an empty text block.\n        return\n      }\n\n      html.push(`<${el}>${textArrayToHtml(block.properties.title)}</${el}>`)\n    } else if([\"numbered_list\", \"bulleted_list\"].includes(type)) {\n      /* Numbered and bulleted lists */\n      const el = {\n        \"numbered_list\": \"ol\",\n        \"bulleted_list\": \"ul\"\n      }[type]\n\n      html.push(`<${el}><li>${textArrayToHtml(block.properties && block.properties.title)}</li></${el}>`)\n    } else if([\"to_do\"].includes(type)) {\n      /* To do list represented by a list of checkbox inputs */\n      const checked = Boolean(block.properties.checked)\n      html.push(`<div class=\"checklist\"><label><input type=\"checkbox\" disabled${checked ? \" checked\" : \"\"}>${textArrayToHtml(block.properties.title)}</label></div>`)\n    } else if([\"code\"].includes(type)) {\n      /* Full code blocks with language */\n      const language = block.properties.language[0][0].toLowerCase().replace(/ /g, \"\")\n      const text = block.properties.title || [[\"\"]]\n\n      // Inject unescaped HTML if code block's language is set to LiveScript\n      const showLive = language === \"livescript\"\n      if(showLive) {\n        html.push(text.map(clip => clip[0]).join(\"\")) // Ignore styling, just take the text\n      } else {\n        const code = textArrayToHtml(text, { br: false, escape: false })\n        let highlighted = code\n        try {\n          // try/catch because this fails when prism doesn't know the language\n          highlighted = prism.highlight(code, prism.languages[language], language)\n        } catch{}\n        html.push(`<pre><code class=\"language-${language}\">${highlighted}</code></pre>`)\n      }\n    } else if([\"callout\"].includes(type)) {\n      /* Callout formatted with emoji from emojicdn.elk.sh or just image */\n      const icon = block.format.page_icon\n      const imageLink = icon.startsWith(\"http\") ? `https://www.notion.so/image/${encodeURIComponent(icon)}?table=block&id=${block.id}` : `https://emojicdn.elk.sh/${icon}`\n      const color = block.format.block_color.split(\"_\")[0]\n      const isBackground = block.format.block_color.split(\"_\").length > 1\n      const text = block.properties.title\n      html.push(`<div class=\"callout${isBackground ? \" background\" : \" color\"}-${color}\"><img src=\"${imageLink}\"><p>${textArrayToHtml(text)}</p></div>`)\n    } else if([\"quote\"].includes(type)) {\n      html.push(`<blockquote>${textArrayToHtml(block.properties.title)}</blockquote>`)\n    } else if([\"divider\"].includes(type)) {\n      html.push(`<hr>`)\n    } else if([\"image\"].includes(type)) {\n      html.push(`<img src=\"https://www.notion.so/image/${encodeURIComponent(block.format.display_source)}?table=block&id=${block.id}\">`)\n    } else if([\"equation\"].includes(type)) {\n      if(!block.properties) {\n        // Equation block is empty\n        return\n      }\n      const equation = block.properties.title[0][0]\n      const equationHtml = katex.renderToString(equation, { throwOnError: false })\n      html.push(`<div class=\"equation\">${equationHtml}</div>`)\n    } else if([\"embed\"].includes(type)) {\n      html.push(`<iframe src=\"${block.properties.source[0][0]}\"></iframe>`)\n    } else if([\"video\"].includes(type)) {\n      html.push(`<iframe src=\"${block.format.display_source}\"></iframe>`)\n    } else {\n      /* Catch blocks without handler method */\n      console.log(`Unhandled block type \"${block.type}\"`, block)\n    }\n  })\n\n  // Only add Katex stylesheet if there's Katex elements.\n  if(html.join(\"\").includes(`class=\"katex\"`)) {\n    html.push(`<link rel=\"stylesheet\" href=\"https://unpkg.com/katex@0.12.0/dist/katex.min.css\">`)\n  }\n\n  const joinedHtml = html.join(\"\")\n  const cleanedHtml = joinedHtml\n                        .replace(/<\\/ol><ol>/g, \"\")\n                        .replace(/<\\/ul><ul>/g, \"\")\n                        .replace(/<\\/div><div class=\"checklist\">/g, \"\")\n  res.send(cleanedHtml)\n}\n"
  },
  {
    "path": "api/table-description.js",
    "content": "/* Return the description of a Notion collection */\n\nconst call = require(\"../notion/call\")\nconst normalizeId = require(\"../notion/normalizeId\")\nconst textArrayToHtml = require(\"../notion/textArrayToHtml\")\n\nmodule.exports = async (req, res) => {\n  const { id:queryId } = req.query\n  const id = normalizeId(queryId)\n\n  if(!id) {\n    return res.json({\n      error: \"no Notion doc ID provided as `id` parameter\"\n    })\n  }\n\n  const {recordMap} = await call(\"loadPageChunk\", {\n    pageId: id,\n    limit: 100,\n    cursor: {\n      stack: [\n        [\n          {\n            table: \"block\",\n            id: id, \n            index: 0\n          }\n        ]\n      ]\n    },\n    chunkNumber: 0,\n    verticalColumns: false\n  });\n\n  const collectionBlock = recordMap.block[id].value;\n\n  if(!collectionBlock) {\n    return res.json({\n      error: \"invalid Notion doc ID, or public access is not enabled on this doc\"\n    })\n  }\n\n  if(!collectionBlock.type.startsWith(\"collection_view\")) {\n    return res.json({\n      error: \"this Notion doc is not a collection\"\n    })\n  }\n\n  const descriptionArray = recordMap.collection[collectionBlock.collection_id].value.description;\n\n  res.send(textArrayToHtml(descriptionArray))\n}\n"
  },
  {
    "path": "api/table.js",
    "content": "/* Return the entries of a table in Notion */\n\nconst call = require(\"../notion/call\")\nconst normalizeId = require(\"../notion/normalizeId\")\nconst textArrayToHtml = require(\"../notion/textArrayToHtml.js\")\nconst getAssetUrl = require(\"../notion/getAssetUrl\")\n\nmodule.exports = async (req, res) => {\n  return res.send(\"This API has been deprecated. Please use the official Notion API (developers.notion.com) instead.\")\n  \n  const { id:queryId } = req.query\n  const id = normalizeId(queryId)\n\n  if(!id) {\n    return res.json({\n      error: \"no Notion doc ID provided as `id` parameter\"\n    })\n  }\n\n  const pageData = await call(\"getRecordValues\", {\n    requests: [\n      {\n        id: id,\n        table: \"block\"\n      }\n    ]\n  })\n\n  if(!pageData.results[0].value) {\n    return res.json({\n      error: \"invalid Notion doc ID, or public access is not enabled on this doc\"\n    })\n  }\n\n  if(!pageData.results[0].value.type.startsWith(\"collection_view\")) {\n    return res.json({\n      error: \"this Notion doc is not a collection\"\n    })\n  }\n\n  const collectionId = pageData.results[0].value.collection_id\n  const collectionViewId = pageData.results[0].value.view_ids[0]\n\n\n  const tableData = await call(\"queryCollection\", {\n    collectionId,\n    collectionViewId,\n    query: {},\n    loader: {\n      type: 'table',\n      limit: 99999,\n      userTimeZone: 'UTC',\n      loadContentCover: true\n    }\n  })\n\n  const subPages = tableData.result.blockIds\n\n  const schema = tableData.recordMap.collection[collectionId].value.schema\n\n  const output = []\n\n  subPages.forEach(id => {\n    const page = tableData.recordMap.block[id]\n\n    const fields = {}\n\n    for(const s in schema) {\n      const schemaDefinition = schema[s]\n      const type = schemaDefinition.type\n      let value = page.value.properties && page.value.properties[s] && page.value.properties[s][0][0]\n\n      if(type === \"checkbox\") {\n        value = value === \"Yes\" ? true : false\n      } else if(value && type === \"date\") {\n        try {\n          value = page.value.properties[s][0][1][0][1]\n        } catch {\n          // it seems the older Notion date format is [[ string ]]\n          value = page.value.properties[s][0][0]\n        }\n      } else if(value && type === \"text\") {\n        value = textArrayToHtml(page.value.properties[s])\n      } else if(value && type === \"file\") {\n        const files = page.value.properties[s].filter(f => f.length > 1)\n        // some items in the files array are for some reason just [\",\"]\n\n        const outputFiles = []\n\n        files.forEach(file => {\n          const s3Url = file[1][0][1]\n          outputFiles.push(getAssetUrl(s3Url, page.value.id))\n        })\n\n        value = outputFiles\n      } else if(value && type === \"multi_select\") {\n        value = value.split(\",\")\n      }\n\n      fields[schemaDefinition.name] = value || undefined\n    }\n\n    output.push({\n      fields,\n      id: page.value.id,\n      emoji: page.value.format && page.value.format.page_icon,\n      created: page.value.created_time,\n      last_edited: page.value.last_edited_time\n    })\n  })\n\n\n  return res.json(output)\n}\n"
  },
  {
    "path": "helpers/escape.js",
    "content": "/* Excape HTML */\n\nmodule.exports = text => text\n                          .replace(/</g, \"&lt;\")\n                          .replace(/>/g, \"&gt;\")"
  },
  {
    "path": "notion/call.js",
    "content": "/* Call a method on Notion's API */\n\nconst fetch = require(\"node-fetch\")\n\nmodule.exports = (methodName, body) => new Promise(resolve => {\n  fetch(`https://www.notion.so/api/v3/${methodName}`, {\n    method: \"POST\",\n    headers: {\n      \"content-type\": \"application/json\"\n    },\n    body: JSON.stringify(body)\n  })\n    .then(res => res.json())\n    .then(json => resolve(json))\n})"
  },
  {
    "path": "notion/getAssetUrl.js",
    "content": "module.exports = (url, blockId) => {\n  const BASE = process.env.NODE_ENV === \"development\" ? \"http://localhost:3000\" : \"https://potion-api.now.sh\"\n\n  return `${BASE}/api/asset?url=${encodeURIComponent(url)}&blockId=${blockId}`\n}"
  },
  {
    "path": "notion/normalizeId.js",
    "content": "/* Normalize a UUID for use in Notion's API */\n\nmodule.exports = (id) => {\n  if(!id) return id\n  if(id.length === 36) return id // Already normalized\n  return `${id.substr(0, 8)}-${id.substr(8, 4)}-${id.substr(12, 4)}-${id.substr(16, 4)}-${id.substr(20)}`\n}"
  },
  {
    "path": "notion/textArrayToHtml.js",
    "content": "/* Turns an array of text, returned by Notion's API, into HTML */\n\nconst katex = require(\"katex\")\n\nconst escapeText = require(\"../helpers/escape\")\n\nmodule.exports = (source, options={ br: true, escape: true }) => {\n  const output = []\n\n  if(!source) return \"\"\n\n  source.forEach(clip => {\n    let text = options.escape ? escapeText(clip[0]) : clip[0]\n\n    if(clip.length === 1) {\n      output.push(text)\n    } else {\n      const modifiers = clip[1]\n\n      modifiers.forEach(mod => {\n        const modCode = mod[0]\n\n        if(modCode === \"b\") {\n          text = `<strong>${text}</strong>`\n        } else if(modCode === \"i\") {\n          text = `<em>${text}</em>`\n        } else if(modCode === \"a\") {\n          text = `<a href=\"${mod[1]}\">${text}</a>`\n        } else if(modCode === \"s\") {\n          text = `<strike>${text}</strike>`\n        } else if(modCode === \"h\") {\n          const color = mod[1].split(\"_\")[0]\n          const isBackground = mod[1].split(\"_\").length > 1\n          text = `<span class=\"${isBackground ? \"background\" : \"color\"}-${color}\">${text}</span>`\n        } else if(modCode === \"c\") {\n          text = `<code>${text}</code>`\n        } else if(modCode === \"e\") {\n          text = `<span class=\"equation\">${katex.renderToString(mod[1], { throwOnError: false })}</span>`\n        } else {\n          console.error(\"Unhandled modification in textArrayToHtml()\", mod)\n        }\n      })\n\n      output.push(text)\n    }\n\n  })\n  \n  return options.br ? output.join(\"\").replace(/\\n/g, \"<br>\") : output.join(\"\")\n}"
  },
  {
    "path": "now.json",
    "content": "{\n  \"redirects\": [\n    { \"source\": \"/\", \"destination\": \"https://github.com/benborgers/potion\" }\n  ],\n  \"rewrites\": [\n    { \"source\": \"/(.*)\", \"destination\": \"/api/$1\" }\n  ],\n  \"headers\": [\n    {\n      \"source\": \"/(.*)\",\n      \"headers\": [\n        {\n          \"key\": \"access-control-allow-origin\",\n          \"value\": \"*\"\n        },\n        {\n          \"key\": \"Cache-Control\",\n          \"value\": \"s-maxage=10\"\n        }\n      ]\n    }\n  ]\n}"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"potion\",\n  \"author\": \"Ben Borgers <borgersbenjamin@gmail.com>\",\n  \"license\": \"ISC\",\n  \"dependencies\": {\n    \"katex\": \"^0.12.0\",\n    \"node-fetch\": \"^2.6.1\",\n    \"prismjs\": \"^1.25.0\"\n  }\n}\n"
  }
]