├── .changeset ├── README.md └── config.json ├── .eslintrc.cjs ├── .github └── workflows │ ├── main.yml │ └── release.yml ├── .gitignore ├── .npmignore ├── .nvmrc ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── public ├── subtle_gltch_logo.png └── welcome_gltch_rounded.png ├── rollup.config.mjs ├── src ├── index.ts ├── notion-blocks-html-parser.ts ├── notion-blocks-md-parser.ts ├── notion-blocks-parser.ts ├── notion-blocks-plaintext-parser.ts ├── notion-cms.ts ├── notion-logger.ts ├── plugins │ ├── head.ts │ ├── images.ts │ ├── linker.ts │ └── render.ts ├── tests │ ├── custom-render.spec.ts │ ├── limiter.spec.ts │ ├── notion-api-mock.spec.ts │ ├── notion-cms-caching.spec.ts │ ├── notion-cms.spec.ts │ └── test.ts ├── types.ts └── utilities.ts └── tsconfig.json /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | // .eslintrc.js 2 | process.env.ESLINT_TSCONFIG = 'tsconfig.json' 3 | 4 | module.exports = { 5 | extends: '@antfu', 6 | rules: { 7 | 'no-console': 'warn', 8 | }, 9 | } 10 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | 7 | jobs: 8 | build: 9 | 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v3 14 | - name: Use Node.js 15 | uses: actions/setup-node@v3 16 | with: 17 | node-version: 19.x 18 | - run: npm ci --include=dev 19 | - run: npm run build --if-present 20 | - run: npm test 21 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | concurrency: ${{ github.workflow }}-${{ github.ref }} 9 | 10 | jobs: 11 | release: 12 | name: Release 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout Repo 16 | uses: actions/checkout@v3 17 | 18 | - name: Setup Node.js 18.x 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: 18.x 22 | 23 | - name: Install Dependencies 24 | run: npm i 25 | 26 | - name: Build & Bundle 27 | run: npm run build:dts 28 | 29 | - name: Test 30 | run: npm test 31 | 32 | - name: Create Release Pull Request or Publish to npm 33 | id: changesets 34 | uses: changesets/action@v1 35 | with: 36 | # This expects you to have a script called release which does a build for your packages and calls changeset publish 37 | publish: npm run release 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 40 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | debug/* 4 | dist 5 | index.d.ts 6 | coverage/ 7 | .vscode/ 8 | my.secrets 9 | .tmp/ 10 | test/ 11 | *.tgz 12 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | test/ 2 | debug/ 3 | src/ 4 | coverage/ 5 | .changeset/ 6 | .env 7 | tsconfig.json 8 | rollup.config.js 9 | dist/.notion-cms 10 | .github/ 11 | public/ 12 | rollup.config.mjs 13 | .eslintrc.cjs 14 | my.secrets 15 | .vscode/ 16 | .tmp/ 17 | *.tgz 18 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 19 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @agency-kit/notion-cms 2 | 3 | ## 0.10.0 4 | 5 | ### Minor Changes 6 | 7 | - 8862d40: Fix: image cache plugin will now only run when a page needs updated (not using cache). 8 | 9 | New: cache PDFs using the image plugin. Long term this will likely be a unified plugin for fetching/caching all AWS S3 hosted Notion assets. 10 | 11 | ### Patch Changes 12 | 13 | - bef414e: Fix: Keep terminal logging mechanism from swallowing important error messages. 14 | 15 | Fix: Throw an error when `rootAlias` is non-existant in Notion database. 16 | 17 | Fix: Show warning when there are multiple pages at the root level AND a `rootAlias` is set (you probably don't want that). 18 | 19 | Fix: Keep from failing when in quiet mode. 20 | 21 | Upd: Improve bundle size a tiny amount by switching terminal colors to picocolors. 22 | 23 | - 26dccd0: Fix: maintain proper spacing in codeblock renders. 24 | 25 | ## 0.9.1 26 | 27 | ### Patch Changes 28 | 29 | - bd28226: Upd: improve terminal output and add quiet flag. 30 | 31 | Fix: [images plugin] make sure existing images don't get overwritten. 32 | 33 | Fix: [linker plugin] proper uuid detection in anchor tags. 34 | 35 | ## 0.9.0 36 | 37 | ### Minor Changes 38 | 39 | - bc57a66: New: optimized content fetching only when content changes. This lets us achieve fastest possible builds 🚄. This comes with an `autoUpdate` flag for turning it off, but you probably shouldn't. 40 | 41 | New: added an every hook \*, recommended for testing only. 42 | 43 | New: added `purgeCache` function that clears the local cache. 44 | 45 | Fix: fixed a bug in linker plugin where non-uuids would get picked up. 46 | 47 | Fix: fixed bug in terminal spinner that was causing ctrl-c not to close the terminal in some cases. 48 | 49 | Fix: fixed typo bug where `rootUrl` was being set to a bogus string. 50 | 51 | ## 0.8.1 52 | 53 | ### Patch Changes 54 | 55 | - 45151f6: New: add core plugins to repo: Images, Linker, Head. 56 | 57 | New: add support for a root alias option that gets its key set to '/' route for convenience. 58 | 59 | ## 0.8.0 60 | 61 | ### Minor Changes 62 | 63 | - d60bd35: Upd: No longer support cjs. 64 | 65 | New: Support rendering link preview blocks. 66 | 67 | New: Add a loading spinner that reports to the terminal for some feedback. 68 | 69 | Upd: Alias `fetch` at `pull` which will match future `push` commands better. 70 | 71 | ## 0.7.2 72 | 73 | ### Patch Changes 74 | 75 | - f493c16: Fix: Caching was broken by a typo in the options checking mechanism in last release. 76 | 77 | Fix: Make sure that the refresh timeout gets set properly whether a number or string is used. 78 | 79 | ## 0.7.1 80 | 81 | ### Patch Changes 82 | 83 | - 521cc9e: Upd: Support mermaid as a highlight js lang. 84 | 85 | Upd: Support detection of changes in options to trigger a re-fetch, not including functions. 86 | 87 | ## 0.7.0 88 | 89 | ### Minor Changes 90 | 91 | - 1575bff: New: Support Notion Link-to-page blocks including with custom renderers. 92 | 93 | New: Support multiple instances of NotionCMS in a project. Before this was limited because they would share a cache which would break things. Fixed by using unique identifiers (last 4 digits of the Notion database id) in the cache name. 94 | 95 | New: Add `_createCMSWalker` static utility class for quickly creating tree walker (walkjs) functions with the appropriate settings for when developing plugins that utilize the `post-tree` hook. 96 | 97 | Upd: Add more API and page stats in CMS metadata and update `duration` to `durationSeconds` for clarity. 98 | 99 | Fix: Make sure that the page content filter accounts for the `post-tree` case where the parent exists. 100 | 101 | ### Patch Changes 102 | 103 | - e4423de: Upd: convert all tests to typescript. 104 | 105 | ## 0.6.0 106 | 107 | ### Minor Changes 108 | 109 | - a3fa1f3: New: support markdown and plaintext in content object. 110 | New: add support for unlimited database pages and unlimited blocks per page. 111 | New: add support for human readable strings in cache timeout option. 112 | New: add run duration stats. 113 | Fix: use Marked options instead of `used` for extensions as they bleed to all instances of Marked (uses a singleton under the hood). 114 | Fix: update mis-typed Notion Block Object Responses. 115 | Fix: proper access kitchen sink test object to fix render test. 116 | Fix: ensure route array is populated. 117 | 118 | ## 0.5.8 119 | 120 | ### Patch Changes 121 | 122 | - 03c9c9a: Fix: Update CI to run tests and publish. Remove vscode settings from publish. 123 | 124 | ## 0.5.7 125 | 126 | ### Patch Changes 127 | 128 | - 4a2b7d3: Upd: use manual npmrc creation in action. 129 | 130 | ## 0.5.6 131 | 132 | ### Patch Changes 133 | 134 | - b4a71fd: Upd: new logo and minor readme updates. 135 | 136 | ## 0.5.5 137 | 138 | ### Patch Changes 139 | 140 | - 09208f8: Fix: Add build to release action but don't test. We test for every PR so this is fine. 141 | 142 | ## 0.5.4 143 | 144 | ### Patch Changes 145 | 146 | - 4e9f98c: Fix: Remove debug console logs from tests 147 | 148 | ## 0.5.3 149 | 150 | ### Patch Changes 151 | 152 | - fc92ad0: Fix: make sure that debug=true doesn't write to files (bug in last build) and remove indents so Marked doesn't wrap them in code tags. 153 | 154 | ## 0.5.2 155 | 156 | ### Patch Changes 157 | 158 | - Upd: add p (and other) tags back into render by taking a parsing pass at the full markdown file generated by marked. 159 | 160 | ## 0.5.1 161 | 162 | ### Patch Changes 163 | 164 | - 60efae2: Fix: Address deeply nested rendering bugs by pulling in the Notion-Blocks-HTML-parser (Thanks @notion-stuff) and reworking some of the internals. 165 | Fix: Remove deprecated `headerIds` option in `marked` and instead use the header-id extension. 166 | 167 | ## 0.5.0 168 | 169 | ### Minor Changes 170 | 171 | - 3d0d5d2: New: Add support for recursive extraction of child blocks from Notion API. Unlocks existing functionality in the Notion markdown parser. 172 | 173 | Upd: Add official Notion debug logs in `debug` mode as well as error handling for API failure cases. 174 | 175 | ## 0.4.1 176 | 177 | ### Patch Changes 178 | 179 | - 65ab3c1: Add debug, local cache path, and notion client to each plugin exec function 180 | 181 | ## 0.4.0 182 | 183 | ### Minor Changes 184 | 185 | - 534abe5: New: Add ability to leverage custom Notion block renderers using a core plugin. 186 | Fix: Import now capable of parsing non-flatted (which is used for caching) format files. 187 | Fix: Proper error handling and fallback to using fresh Notion API call when cache fails. 188 | Upd: Improve test coverage to 95+, remove unused code. 189 | Upd: Expose tree walking filter as static utility class for use in plugins. 190 | 191 | ### Patch Changes 192 | 193 | - 78be33a: If a coverImage property exists, don't overwrite it when we look for the backup cover image. 194 | - 5866586: Add working sourcemaps to the build. 195 | 196 | ## 0.3.0 197 | 198 | ### Minor Changes 199 | 200 | - 9157243: Fix: ensure that tree walkers only operate on page content nodes. 201 | Upd: Remove ancestor nodes from public API. 202 | Upd: Add support for starting a walk/async walk from a partial path. 203 | New: Add helper method to capture only page data and remove that pages children. Useful for sending individual page data to client without sending its sub tree. 204 | - 8ae6713: New: Add import plugin hook and remove previous state importing in constructor. Now you have to manually import anytime you want to revive a CMS. 205 | 206 | ### Patch Changes 207 | 208 | - 84433cb: Upd: remove otherProps to keep from polluting final tree structure. Guide users down the path of building plugins to extract props at an earlier stage. 209 | - 8ae6713: New: add support for a plugin function returning an array with multiple plugin hook executors. 210 | - 41015e2: Fix: increase page size to maximum number of blocks when pulling page content. 211 | 212 | ## 0.2.0 213 | 214 | ### Minor Changes 215 | 216 | - 93cc1e0: Improved the internal CMS tree building algorithm and added public API for walking the tree. 217 | 218 | This fixed a bug where deeply nested pages didn't get inserted into the tree as intended, another bug 219 | where searching for pages by their key didn't allow for duplicated keys at different places in the structure. 220 | 221 | Rewrote tests to be more maintainable and test all public API surfaces. 222 | 223 | Added references to ancestor nodes in the CMS tree for usage in plugins. This required proper serialization/deserialization 224 | of objects with circular references (the ancestors) but also functions, which are required in the templating plugin and are generally useful to store in the cache. 225 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 2 | PRs welcome. 3 | 4 | If you make changes, use `npx changeset`. Versioning and publishing is done via GitHub Actions. 5 | 6 | Please make tests. We use `nock` to simulate the Notion API so remember that when you are making changes to your Notion test page and the API is returning stale results... you probably forgot to run with Nock turned off. 7 | 8 | Or better yet, use `Insomnia` and copy/paste the API results into `notion-api-mock.spec.mjs`. 9 | 10 | 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Jacob Milhorn 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |
with the simplicity of Ghost's Content API.
11 | 12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
30 | docs | 31 | quickstart 32 |
33 | 34 | ## Features 35 | 36 | 🏗️ Framework agnostic - it’s just JS. 37 | 🌲 Build a collection-based CMS tree from your Notion database. 38 | 🎚️ Leverage database structure to control your routing structure. 39 | ⚙️ Geared for Static Site Generation. 40 | 📑 Transform Notion blocks → Markdown, plaintext, and (customizable) html. 41 | 🗃️ Optimized Content Caching for super fast builds. 42 | 🧩 Plugin ready with some powerful core plugins on the way. 43 | 🦾 Tagging, filtering, path queries, and tree-walking utilities. 44 | ⌨️ Totally Typesafe. 45 | 46 | ## Install 47 | 48 | ``` npm install @agency-kit/notion-cms ``` 49 | ```pnpm add @agency-kit/notion-cms``` 50 | ```yarn add @agency-kit/notion-cms``` 51 | 52 | ## Why. 53 | 54 | Notion excels at managing content. It also has a great API and SDK. But why is it so challenging to _actually_ leverage Notion as a CMS (Content Management System) in production? 55 | 56 | Until recently there wasn't support for sub-pages (sub-items in a database) in Notion. So most existing Notion-as-a-cms solutions don't provide a way to leverage this new feature, which turns out to be crucial for building a collection-based CMS. Another barrier is that pulling content from Notion can be time consuming. Responses can take a few seconds at times, the API provides a lot of data to sift through, and multiple calls to different endpoints are required in order to go from CMS database to the content for each page. All of this together results in a less than excellent developer experience when using a static site generator that often makes requests on each build. 57 | 58 | NotionCMS exists to address each of these issues and provide an excellent developer experience while using Notion as your Content Management System. 59 | 60 | ## The Database Structure 61 | 62 | In order to make use of NotionCMS, you have to subscribe to a specific database structure. Its an extremely generic design that gives you all the things you need for basic sites but lends flexibility for types of content other than the standard web page, blog post etc. 63 | 64 | See the structure in this template [this template](https://cooked-shovel-3c3.notion.site/NotionCMS-Quickstart-Database-Template-719f1f9d1547465d96bcd7e80333c831?pvs=4). 65 | 66 | ## Basic Usage 67 | 68 | ```javascript 69 | 70 | // initialize 71 | const myCoolCMS = new NotionCMS({ 72 | databaseId: 'e4fcd5b3-1d6a-4afd-b951-10d56ce436ad', 73 | notionAPIKey: process.env.NOTION, 74 | // Other options 75 | }) 76 | 77 | // Pull down all Notion content 78 | await myCoolCMS.pull() 79 | 80 | // Access the routes here: 81 | console.log(myCoolCMS.routes) 82 | 83 | // Access the page content here: 84 | console.log(myCoolCMS.data) 85 | 86 | // Access paths like this: 87 | const postA = myCoolCMS.data['/posts']['/how-to-build-a-blog-with-notion'] 88 | const postB = myCoolCMS.data['/posts']['/how-to-use-notion-cms'] 89 | 90 | ``` 91 | 92 | ## Advanced Usage 93 | 94 | ```javascript 95 | 96 | const customPlugin = () => { 97 | return { 98 | name: 'my-custom-plugin', 99 | hook: 'pre-parse', // other option is 'post-parse' 100 | // list of blocks to transform. 101 | // If hook is post parse, its the string of html parsed from blocks 102 | exec: (blocksOrHtml) => { 103 | // do some transformations, 104 | const transformedBlocksOrHtml = someXform(blocksOrHtml) 105 | return transformedBlocksOrHtml 106 | } 107 | } 108 | } 109 | 110 | const myAdvancedCMS = new NotionCMS({ 111 | databaseId: 'e4fcd5b3-1d6a-4afd-b951-10d56ce436ad', 112 | notionAPIKey: process.env.NOTION, 113 | rootUrl: 'https://mycoolsite.com', 114 | localCacheDirectory: `${process.cwd()}/localcache/`, 115 | refreshTimeout: '1 hour', 116 | plugins: [customPlugin()], 117 | }) 118 | 119 | await myAdvancedCMS.pull() 120 | 121 | ``` 122 | 123 | See the full [API reference](https://www.agencykit.so/notion-cms/guide/api/). 124 | 125 | ## Some Helper methods 126 | 127 | ```javascript 128 | // returns page reference 129 | myCMS.queryByPath('/full/path/to/page') 130 | 131 | // returns an array of only child pages of a page looked up using the key. 132 | // This runs queryByPath under the hood so you can save a step 133 | myCMS.filterSubPages('/full/path/to/page' /* or Page reference*/) 134 | 135 | // returns page object without any children - just the content. Useful for serializing and sending a 136 | // single pages data to the client. 137 | myCoolCMS.rejectSubPages('/full/path/to/page' /* or Page reference*/) 138 | 139 | // Get tagged collections this way or by passing a single tag: 140 | const tagged = myCoolCMS.getTaggedCollection(['blog', 'programming']) 141 | 142 | // walk the CMS from the root node and perform some operation for each node. 143 | // the node parameter will be of type PageContent so you have access to all of the page data 144 | myCoolCMS.walk(node => console.log(node), '/partial/path/to/start') 145 | 146 | // for async callbacks 147 | await myCoolCMS.asyncWalk(async node => await asynchronousFunc()) 148 | ``` 149 | 150 | ## Project Stats 151 | 152 |  153 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@agency-kit/notion-cms", 3 | "type": "module", 4 | "version": "0.10.0", 5 | "description": "NotionCMS. Turn Notion into a full-fledged headless CMS (content management system).", 6 | "author": "Jacob Milhorn", 7 | "license": "MIT", 8 | "homepage": "https://www.agencykit.so/notion-cms/guide/", 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/agency-kit/notion-cms.git" 12 | }, 13 | "bugs": { 14 | "url": "https://github.com/agency-kit/notion-cms/issues" 15 | }, 16 | "keywords": [ 17 | "notion", 18 | "content", 19 | "content management", 20 | "cms", 21 | "notion-cms", 22 | "notion cms", 23 | "NotionCMS", 24 | "notion content management", 25 | "static site generation", 26 | "ssg", 27 | "ssr", 28 | "server side rendered", 29 | "server rendered", 30 | "node", 31 | "11ty", 32 | "eleventy", 33 | "headless cms", 34 | "headless content management system", 35 | "framework agnostic", 36 | "agnostic" 37 | ], 38 | "main": "./dist/index.js", 39 | "types": "./index.d.ts", 40 | "scripts": { 41 | "dev": "tsc --watch", 42 | "watch": "NODE_OPTIONS=--experimental-vm-modules NODE_NO_WARNINGS=1 chokidar \"src/**/*.ts\" -c \"rollup -c && tsc --project ./tsconfig.json && npx uvu test\"", 43 | "test:no-mock": "node test/notion-cms.spec.js true", 44 | "build": "rm -rf dist && tsc --project ./tsconfig.json && rollup -c", 45 | "build:dts": "rm -rf dist && tsc --project ./tsconfig.json && rollup -c && npm run dts", 46 | "dts": "dts-bundle-generator -o index.d.ts src/index.ts --no-check && rm -rf .tmp", 47 | "test": "uvu test", 48 | "build:test": "npm run build:dts && c8 npm test", 49 | "lint": "eslint .", 50 | "lint:fix": "eslint . --fix", 51 | "release": "changeset publish" 52 | }, 53 | "dependencies": { 54 | "@clack/prompts": "^0.6.3", 55 | "@notionhq/client": "^2.2.3", 56 | "file-type": "^18.5.0", 57 | "flatted": "^3.2.7", 58 | "highlight.js": "^11.7.0", 59 | "human-interval": "^2.0.1", 60 | "lodash": "^4.17.21", 61 | "marked": "^5.0.2", 62 | "marked-gfm-heading-id": "^3.0.3", 63 | "nanoid": "^4.0.2", 64 | "notion-utils": "^6.16.0", 65 | "picocolors": "^1.0.0", 66 | "serialize-javascript": "^6.0.1", 67 | "sharp": "^0.32.4", 68 | "walkjs": "^4.0.5", 69 | "zod": "^3.21.4" 70 | }, 71 | "devDependencies": { 72 | "@antfu/eslint-config": "^0.38.6", 73 | "@changesets/cli": "^2.26.1", 74 | "@notion-stuff/v4-types": "^6.0.0", 75 | "@rollup/plugin-terser": "^0.4.0", 76 | "@types/lodash": "^4.14.191", 77 | "@types/marked": "^4.0.8", 78 | "@types/node": "^20.1.5", 79 | "@types/serialize-javascript": "^5.0.2", 80 | "bottleneck": "^2.19.5", 81 | "c8": "^7.13.0", 82 | "chokidar": "^3.5.3", 83 | "chokidar-cli": "^3.0.0", 84 | "dotenv": "^16.0.3", 85 | "dts-bundle-generator": "^8.0.0", 86 | "esbuild": "^0.17.11", 87 | "nock": "^13.3.0", 88 | "rollup": "^3.19.1", 89 | "rollup-plugin-esbuild": "^5.0.0", 90 | "simple-git-hooks": "^2.8.1", 91 | "terser": "^5.16.6", 92 | "tslib": "^2.5.0", 93 | "typescript": "^4.9.5", 94 | "uvu": "^0.5.6" 95 | }, 96 | "simple-git-hooks": { 97 | "pre-commit": "npx lint-staged" 98 | }, 99 | "lint-staged": { 100 | "*": "eslint --fix" 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /public/subtle_gltch_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agency-kit/notion-cms/6a32a0fc3fbca5655cbe4716f26bc6fa7fa2ee5d/public/subtle_gltch_logo.png -------------------------------------------------------------------------------- /public/welcome_gltch_rounded.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agency-kit/notion-cms/6a32a0fc3fbca5655cbe4716f26bc6fa7fa2ee5d/public/welcome_gltch_rounded.png -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from 'rollup-plugin-esbuild' 2 | import terser from '@rollup/plugin-terser' 3 | 4 | export default [{ 5 | input: 'src/index.ts', 6 | plugins: [esbuild()], 7 | output: [ 8 | { format: 'esm', file: './dist/index.js' }, 9 | ], 10 | }, 11 | { 12 | input: 'src/index.ts', 13 | plugins: [esbuild({ sourceMap: true }), terser()], 14 | output: [ 15 | { format: 'esm', file: './dist/index.min.js', sourcemap: 'inline' }, 16 | ], 17 | }, 18 | { 19 | input: 'src/tests/test.ts', 20 | plugins: [esbuild()], 21 | output: [ 22 | { format: 'esm', file: './test/test.mjs' }, 23 | ], 24 | }] 25 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export type { 2 | Properties, 3 | Options, 4 | CMS, 5 | Route, 6 | Page, 7 | PageObjectTitle, 8 | PageObjectRelation, 9 | PageObjectUser, 10 | PageMultiSelect, 11 | PageRichText, 12 | PageSelect, 13 | Cover, 14 | RouteObject, 15 | Plugin, 16 | PluginPassthrough, 17 | } from './types' 18 | 19 | export type { BlockRenderers } from './notion-blocks-parser' 20 | export { default as NotionBlocksParser } from './notion-blocks-parser' 21 | export { default } from './notion-cms' 22 | export { default as blocksRenderPlugin } from './plugins/render' 23 | export { default as Linker } from './plugins/linker' 24 | export { default as Images } from './plugins/images' 25 | export { default as Head } from './plugins/head' 26 | -------------------------------------------------------------------------------- /src/notion-blocks-html-parser.ts: -------------------------------------------------------------------------------- 1 | import type { Renderer as MarkedRenderer } from 'marked' 2 | import { marked } from 'marked' 3 | import hljs from 'highlight.js' 4 | import type { Blocks } from '@notion-stuff/v4-types' 5 | import _ from 'lodash' 6 | 7 | // @ts-expect-error module 8 | import { gfmHeadingId } from 'marked-gfm-heading-id' 9 | import type NotionBlocksMarkdownParser from './notion-blocks-md-parser' 10 | 11 | export default class NotionBlocksHtmlParser { 12 | markdownParser: NotionBlocksMarkdownParser 13 | renderer: MarkedRenderer 14 | markedOptions 15 | debug: boolean 16 | 17 | constructor(parser: NotionBlocksMarkdownParser, debug?: boolean) { 18 | this.markdownParser = parser 19 | this.debug = debug || false 20 | 21 | this.renderer = new marked.Renderer() 22 | this.renderer.code = this._highlight.bind(this) 23 | this.markedOptions = { 24 | renderer: this.renderer, 25 | pedantic: false, 26 | mangle: false, 27 | gfm: true, 28 | headerIds: true, 29 | breaks: false, 30 | sanitize: false, 31 | smartypants: false, 32 | xhtml: false, 33 | // eslint-disable-next-line @typescript-eslint/no-unsafe-call 34 | extensions: [gfmHeadingId({ prefix: '' })], 35 | } 36 | marked.use({ silent: true }) 37 | // This is a workaround so that hljs doesn't complain about mermaid not being a registered lang. 38 | hljs.registerAliases('mermaid', { languageName: 'plaintext' }) 39 | } 40 | 41 | marked(md: string): string { 42 | return marked(md, this.markedOptions) 43 | } 44 | 45 | parse(blocks: Blocks) { 46 | let markdown = this.markdownParser.parse(blocks) 47 | // if (this.debug) 48 | // fs.writeFileSync('./debug/parsed.md', markdown) 49 | // Take another pass to wrap any deeply nested mixed HTML content's inner text in p tags 50 | markdown = this._mixedHTML(markdown) 51 | // if (this.debug) 52 | // fs.appendFileSync('./debug/parsed.md', `--------ALTERED----------**\n\n\n${markdown}`) 53 | return marked(markdown, this.markedOptions) 54 | } 55 | 56 | _highlight(code: string, lang: string | undefined): string { 57 | let language 58 | if (lang === 'mermaid') 59 | language = 'mermaid' 60 | else 61 | language = (lang && hljs.getLanguage(lang)) ? lang : 'plaintext' 62 | const higlighted = hljs.highlight(code, { language }) 63 | const langClass = `language-${ 64 | (!language || language.includes('plain')) ? 'none' : language}` 65 | return `${higlighted.value}`
66 | }
67 |
68 | _mixedHTML(mixedHtml: string) {
69 | return mixedHtml.replaceAll(/[^<>]+?(?=<\/)/g, (match) => {
70 | // Must trim or Marked classifies text preceded by tabs as indented code.
71 | const tokens = marked.lexer(_.trim(match), this.markedOptions)
72 | return marked.parser(tokens, this.markedOptions)
73 | })
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/src/notion-blocks-md-parser.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | Annotations,
3 | AudioBlock,
4 | Blocks,
5 | BulletedListItemBlock,
6 | CalloutBlock,
7 | CalloutIconEmoji,
8 | CalloutIconExternal,
9 | CalloutIconFile,
10 | CodeBlock,
11 | EmbedBlock,
12 | ExternalFileWithCaption,
13 | FileBlock,
14 | FileWithCaption,
15 | HeadingBlock,
16 | ImageBlock,
17 | LinkPreviewBlock,
18 | LinkToPageBlock,
19 | NumberedListItemBlock,
20 | PDFBlock,
21 | ParagraphBlock,
22 | QuoteBlock,
23 | RichText,
24 | RichTextEquation,
25 | RichTextMention,
26 | RichTextText,
27 | ToDoBlock,
28 | ToggleBlock,
29 | VideoBlock,
30 | } from '@notion-stuff/v4-types'
31 | import { uuidToId } from 'notion-utils'
32 |
33 | const EOL_MD = '\n'
34 |
35 | export default class NotionBlocksMarkdownParser {
36 | parse(blocks: Blocks, depth = 0): string {
37 | return blocks
38 | .reduce((markdown, childBlock) => {
39 | let childBlockString = ''
40 | // @ts-expect-error children
41 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
42 | if (childBlock.has_children && childBlock[childBlock.type].children) {
43 | childBlockString = ' '
44 | .repeat(depth)
45 | .concat(
46 | childBlockString,
47 | // @ts-expect-error children
48 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument
49 | this.parse(childBlock[childBlock.type].children, depth + 2),
50 | )
51 | }
52 |
53 | if (childBlock.type === 'unsupported') {
54 | markdown += 'NotionAPI Unsupported'.concat(
55 | EOL_MD.repeat(2),
56 | childBlockString,
57 | )
58 | }
59 |
60 | if (childBlock.type === 'paragraph')
61 | markdown += this.parseParagraph(childBlock).concat(childBlockString)
62 |
63 | if (childBlock.type === 'code')
64 | markdown += this.parseCodeBlock(childBlock).concat(childBlockString)
65 |
66 | if (childBlock.type === 'quote')
67 | markdown += this.parseQuoteBlock(childBlock).concat(childBlockString)
68 |
69 | if (childBlock.type === 'callout') {
70 | markdown
71 | += this.parseCalloutBlock(childBlock).concat(childBlockString)
72 | }
73 |
74 | if (childBlock.type.startsWith('heading_')) {
75 | const headingLevel = Number(childBlock.type.split('_')[1])
76 | markdown += this.parseHeading(
77 | childBlock as HeadingBlock,
78 | headingLevel,
79 | ).concat(childBlockString)
80 | }
81 |
82 | if (childBlock.type === 'bulleted_list_item') {
83 | markdown
84 | += this.parseBulletedListItems(childBlock).concat(childBlockString)
85 | }
86 |
87 | if (childBlock.type === 'numbered_list_item') {
88 | markdown
89 | += this.parseNumberedListItems(childBlock).concat(childBlockString)
90 | }
91 |
92 | if (childBlock.type === 'to_do')
93 | markdown += this.parseTodoBlock(childBlock).concat(childBlockString)
94 |
95 | if (childBlock.type === 'toggle') {
96 | markdown += this.parseToggleBlock(childBlock).replace(
97 | '{{childBlock}}',
98 | childBlockString,
99 | )
100 | }
101 |
102 | if (childBlock.type === 'image')
103 | markdown += this.parseImageBlock(childBlock).concat(childBlockString)
104 |
105 | if (childBlock.type === 'embed')
106 | markdown += this.parseEmbedBlock(childBlock).concat(childBlockString)
107 |
108 | if (childBlock.type === 'audio')
109 | markdown += this.parseAudioBlock(childBlock).concat(childBlockString)
110 |
111 | if (childBlock.type === 'video')
112 | markdown += this.parseVideoBlock(childBlock).concat(childBlockString)
113 |
114 | if (childBlock.type === 'file')
115 | markdown += this.parseFileBlock(childBlock).concat(childBlockString)
116 |
117 | if (childBlock.type === 'pdf')
118 | markdown += this.parsePdfBlock(childBlock).concat(childBlockString)
119 |
120 | if (childBlock.type === 'link_to_page')
121 | markdown += this.parseLinkToPageBlock(childBlock).concat(childBlockString)
122 |
123 | if (childBlock.type === 'divider')
124 | markdown += EOL_MD.concat('---', EOL_MD, childBlockString)
125 |
126 | if (childBlock.type === 'link_preview')
127 | markdown += this.parseLinkPreview(childBlock).concat(childBlockString)
128 |
129 | return markdown
130 | }, '')
131 | .concat(EOL_MD).replace(/\t/g, ' ')
132 | }
133 |
134 | parseLinkPreview(linkPreviewBlock: LinkPreviewBlock): string {
135 | return `