├── .changeset ├── README.md ├── config.json ├── wet-carrots-shout.md └── yellow-dragons-admire.md ├── .editorconfig ├── .eslintignore ├── .eslintrc.cjs ├── .github └── workflows │ ├── main.yml │ └── publish.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── assets ├── toggle_moon.svg └── toggle_sun.svg ├── build-body-js-string.sh ├── package.json ├── pnpm-lock.yaml ├── src ├── append-js-pf.ts ├── append-js.ts ├── cli │ ├── copy-files.ts │ ├── index.ts │ ├── init-repo.ts │ ├── parser-config.ts │ ├── site-config.ts │ └── validators.ts ├── config-helpers.ts ├── handlers │ ├── handle-api.ts │ ├── handle-app-js.ts │ ├── handle-favicon.ts │ ├── handle-js.ts │ ├── handle-options.ts │ ├── handle-other.ts │ ├── handle-sitemap.ts │ └── index.ts ├── index.ts ├── reverse-proxy-init.ts ├── reverse-proxy.ts ├── rewriters │ ├── _body-js-string.ts │ ├── body-rewriter.ts │ ├── body.js │ ├── element-handler.ts │ ├── head-rewriter.ts │ ├── index.ts │ └── meta-rewriter.ts └── types.ts ├── templates └── default │ ├── .editorconfig │ ├── _gitignore │ ├── package.json │ ├── src │ ├── _build-page-script-js-string.js │ ├── _page-script-js-string.ts │ ├── index.ts │ ├── page-script.js │ └── site-config.ts │ ├── tsconfig.json │ └── wrangler.toml └── 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@3.0.0/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "restricted", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /.changeset/wet-carrots-shout.md: -------------------------------------------------------------------------------- 1 | --- 2 | "notehost": patch 3 | --- 4 | 5 | Asks for main Notion page ID, uses chalk for output 6 | -------------------------------------------------------------------------------- /.changeset/yellow-dragons-admire.md: -------------------------------------------------------------------------------- 1 | --- 2 | "notehost": patch 3 | --- 4 | 5 | Update README 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | tab_width = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | semicolon = false 12 | quote_type = single 13 | 14 | [*.yml] 15 | indent_style = space 16 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /**/node_modules/* 2 | node_modules/ 3 | 4 | dist/ 5 | build/ 6 | out/ 7 | templates/ 8 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-undef 2 | module.exports = { 3 | root: true, 4 | env: { 5 | browser: true, 6 | es2021: true, 7 | }, 8 | extends: [ 9 | 'eslint:recommended', 10 | 'plugin:@typescript-eslint/recommended', 11 | // "plugin:prettier/recommended", 12 | // "prettier", 13 | ], 14 | parser: '@typescript-eslint/parser', 15 | parserOptions: { 16 | ecmaVersion: 12, 17 | sourceType: 'module', 18 | }, 19 | plugins: [ 20 | '@typescript-eslint', 21 | '@stylistic', 22 | // "prettier", 23 | ], 24 | rules: { 25 | 'import/prefer-default-export': ['off'], 26 | 'import/no-unresolved': ['off'], 27 | 'import/extensions': ['off'], 28 | 29 | 'no-restricted-syntax': ['off'], 30 | 'no-use-before-define': ['off'], 31 | 'no-case-declarations': ['off'], 32 | 33 | '@typescript-eslint/no-unused-vars': 'error', 34 | '@typescript-eslint/no-explicit-any': 'error', 35 | // '@typescript-eslint/no-floating-promises': 'error', 36 | // '@typescript-eslint/promise-function-async': 'error', 37 | // '@typescript-eslint/no-misused-promises': 'error', 38 | // '@typescript-eslint/require-await': 'error', 39 | // '@typescript-eslint/no-shadow': 'error', 40 | 41 | // '@stylistic/object-curly-spacing': ['error', 'always'], 42 | // '@stylistic/object-curly-newline': [ 43 | // 'error', 44 | // { 45 | // ObjectExpression: { multiline: true, minProperties: 4, consistent: true }, 46 | // ObjectPattern: { multiline: true, minProperties: 4, consistent: true }, 47 | // }, 48 | // ], 49 | // '@stylistic/switch-colon-spacing': ['error', { after: false, before: false }], 50 | '@stylistic/padding-line-between-statements': [ 51 | 'error', 52 | { 53 | blankLine: 'always', 54 | prev: '*', 55 | next: [ 56 | 'block', 57 | 'block-like', 58 | 'cjs-export', 59 | 'class', 60 | 'const', 61 | 'export', 62 | 'import', 63 | 'let', 64 | 'var', 65 | ], 66 | }, 67 | { 68 | blankLine: 'always', 69 | prev: [ 70 | 'block', 71 | 'block-like', 72 | 'cjs-export', 73 | 'class', 74 | 'const', 75 | 'export', 76 | 'import', 77 | 'let', 78 | 'var', 79 | ], 80 | next: '*', 81 | }, 82 | { 83 | blankLine: 'never', 84 | prev: ['const', 'let', 'var'], 85 | next: ['const', 'let', 'var'], 86 | }, 87 | { 88 | blankLine: 'any', 89 | prev: ['export', 'import'], 90 | next: ['export', 'import'], 91 | }, 92 | { blankLine: 'always', prev: '*', next: 'return' }, 93 | ], 94 | }, 95 | }; 96 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - "**" 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - uses: pnpm/action-setup@v2 13 | with: 14 | version: 7 15 | - uses: actions/setup-node@v3 16 | with: 17 | node-version: 18.x 18 | cache: "pnpm" 19 | 20 | - run: pnpm install --frozen-lockfile 21 | - run: pnpm run lint && pnpm run build 22 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | on: 3 | workflow_run: 4 | workflows: [CI] 5 | branches: [main] 6 | types: [completed] 7 | 8 | concurrency: ${{ github.workflow }}-${{ github.ref }} 9 | 10 | permissions: 11 | contents: write 12 | pull-requests: write 13 | 14 | jobs: 15 | publish: 16 | if: ${{ github.event.workflow_run.conclusion == 'success' }} 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v3 20 | - uses: pnpm/action-setup@v2 21 | with: 22 | version: 7 23 | - uses: actions/setup-node@v3 24 | with: 25 | node-version: 18.x 26 | cache: "pnpm" 27 | 28 | - run: pnpm install --frozen-lockfile 29 | - name: Create Release Pull Request or Publish 30 | id: changesets 31 | uses: changesets/action@v1 32 | with: 33 | publish: pnpm run release 34 | env: 35 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 36 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | 3 | logs 4 | _.log 5 | npm-debug.log_ 6 | yarn-debug.log* 7 | yarn-error.log* 8 | lerna-debug.log* 9 | .pnpm-debug.log* 10 | 11 | # Diagnostic reports (https://nodejs.org/api/report.html) 12 | 13 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 14 | 15 | # Runtime data 16 | 17 | pids 18 | _.pid 19 | _.seed 20 | \*.pid.lock 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | 24 | lib-cov 25 | 26 | # Coverage directory used by tools like istanbul 27 | 28 | coverage 29 | \*.lcov 30 | 31 | # nyc test coverage 32 | 33 | .nyc_output 34 | 35 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 36 | 37 | .grunt 38 | 39 | # Bower dependency directory (https://bower.io/) 40 | 41 | bower_components 42 | 43 | # node-waf configuration 44 | 45 | .lock-wscript 46 | 47 | # Compiled binary addons (https://nodejs.org/api/addons.html) 48 | 49 | build/Release 50 | 51 | # Dependency directories 52 | 53 | node_modules/ 54 | jspm_packages/ 55 | 56 | # Snowpack dependency directory (https://snowpack.dev/) 57 | 58 | web_modules/ 59 | 60 | # TypeScript cache 61 | 62 | \*.tsbuildinfo 63 | 64 | # Optional npm cache directory 65 | 66 | .npm 67 | 68 | # Optional eslint cache 69 | 70 | .eslintcache 71 | 72 | # Optional stylelint cache 73 | 74 | .stylelintcache 75 | 76 | # Microbundle cache 77 | 78 | .rpt2_cache/ 79 | .rts2_cache_cjs/ 80 | .rts2_cache_es/ 81 | .rts2_cache_umd/ 82 | 83 | # Optional REPL history 84 | 85 | .node_repl_history 86 | 87 | # Output of 'npm pack' 88 | 89 | \*.tgz 90 | 91 | # Yarn Integrity file 92 | 93 | .yarn-integrity 94 | 95 | # dotenv environment variable files 96 | 97 | .env 98 | .env.development.local 99 | .env.test.local 100 | .env.production.local 101 | .env.local 102 | 103 | # parcel-bundler cache (https://parceljs.org/) 104 | 105 | .cache 106 | .parcel-cache 107 | 108 | # Next.js build output 109 | 110 | .next 111 | out 112 | 113 | # Nuxt.js build / generate output 114 | 115 | .nuxt 116 | dist 117 | 118 | # Gatsby files 119 | 120 | .cache/ 121 | 122 | # Comment in the public line in if your project uses Gatsby and not Next.js 123 | 124 | # https://nextjs.org/blog/next-9-1#public-directory-support 125 | 126 | # public 127 | 128 | # vuepress build output 129 | 130 | .vuepress/dist 131 | 132 | # vuepress v2.x temp and cache directory 133 | 134 | .temp 135 | .cache 136 | 137 | # Docusaurus cache and generated files 138 | 139 | .docusaurus 140 | 141 | # Serverless directories 142 | 143 | .serverless/ 144 | 145 | # FuseBox cache 146 | 147 | .fusebox/ 148 | 149 | # DynamoDB Local files 150 | 151 | .dynamodb/ 152 | 153 | # TernJS port file 154 | 155 | .tern-port 156 | 157 | # Stores VSCode versions used for testing VSCode extensions 158 | 159 | .vscode-test 160 | 161 | # yarn v2 162 | 163 | .yarn/cache 164 | .yarn/unplugged 165 | .yarn/build-state.yml 166 | .yarn/install-state.gz 167 | .pnp.\* 168 | 169 | # wrangler project 170 | 171 | .dev.vars 172 | .wrangler/ 173 | .vscode 174 | .git 175 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore artifacts: 2 | build 3 | coverage 4 | dist 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "arrowParens": "always", 6 | "semi": false, 7 | "singleQuote": true, 8 | "bracketSameLine": false, 9 | "printWidth": 120 10 | } 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # notehost 2 | 3 | ## 1.0.6 4 | 5 | ### Patch Changes 6 | 7 | - d064689: fix output permissions for bash script 8 | 9 | ## 1.0.5 10 | 11 | ### Patch Changes 12 | 13 | - 78fc22d: Working cli UI 14 | - 78fc22d: Updated readme, prepared types for cli (which is not implemented yet) 15 | 16 | ## 1.0.2 17 | 18 | ### Patch Changes 19 | 20 | - c4c611e: Testing changeset 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024, Vels (Itzhak Lobak), Inc. All rights reserved. 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NoteHost: Free Hosting for Notion Sites! 2 | 3 | ## Using: 4 | 5 | - Cloudflare DNS 6 | - Cloudflare workers 7 | - Reverse proxy implementation 8 | - TypeScript 9 | 10 | ## Supports: 11 | 12 | - Custom meta tags 13 | - Page slugs 14 | - Dark mode toggle 15 | - Custom JS for head and body 16 | - Custom fonts (using [Google Fonts](https://fonts.google.com/)) 17 | - Subdomain redirect (e.g. www) 18 | 19 |
20 | 21 | ## How to use: 22 | 23 | ### Setup your Cloudflare account 24 | 25 | --- 26 | 27 | 1. Add your domain to Cloudflare. Make sure that DNS doesn't have `A` records for your domain and no `CNAME` alias for `www` 28 | 2. Create a new worker on Cloudflare and give it a meaningful name, e.g. `yourdomain-com-notion-proxy` 29 | 3. Keep the default example worker code, we will overwrite it anyway during deploy (see below) 30 | 31 | > [!TIP] 32 | > A bit outdated but detailed description on how to add your domain to Cloudflare and create a worker is [here](https://stephenou.notion.site/stephenou/Fruition-Free-Open-Source-Toolkit-for-Building-Websites-with-Notion-771ef38657244c27b9389734a9cbff44). 33 | > 34 | > Search for "Step 1: Set up your Cloudflare account". 35 | > 36 | > If someone wishes to create an up-to-date tutorial for NoteHost, please submit a pull request 😉 37 | 38 |
39 | 40 | ### Generate your NoteHost worker 41 | 42 | --- 43 | 44 | Go into your working directory and run: 45 | 46 | ```sh 47 | npx notehost init 48 | ``` 49 | 50 | Follow the prompts to confirm your domain name and enter the requested information. You can change these settings later via the configuration file. 51 | 52 | NoteHost will create a directory with the name of your domain. In this directory you will see the following files: 53 | 54 | ``` 55 | . 56 | ├── build-page-script-js-string.sh helper script, details below 57 | ├── package.json test & deploy your website, see realtime logs 58 | ├── tsconfig.json types config 59 | ├── wrangler.toml your Cloudflare worker config 60 | └── src 61 | ├── _page-script-js-string.ts generated by helper script 62 | ├── index.ts runs reverse proxy 63 | ├── page-script.js your custom JS page script 64 | └── site-config.ts your domain and website config 65 | ``` 66 | 67 | Go into this directory and run 68 | 69 | ```sh 70 | npm install 71 | ``` 72 | 73 |
74 | 75 | ### Configure your domain 76 | 77 | --- 78 | 79 | Make sure that wrangler is authenticated with your Cloudflare account 80 | 81 | ```sh 82 | npx wrangler login 83 | ``` 84 | 85 | 1. Edit `wrangler.toml` and make sure that the `name` field matches your worker name in Cloudflare 86 | 2. Edit `site-config.ts` and set all the necessary options: domain, metadata, slugs, subdomain redirects, etc. All settings should be self explanatory, I hope 😊 87 | 88 | ```ts filename="src/site-config.ts" 89 | import { NoteHostSiteConfig, googleTag } from 'notehost' 90 | import { PAGE_SCRIPT_JS_STRING } from './_page-script-js-string' 91 | 92 | // Set this to your Google Tag ID from Google Analytics 93 | const GOOGLE_TAG_ID = '' 94 | 95 | export const SITE_CONFIG: NoteHostSiteConfig = { 96 | domain: 'yourdomain.com', 97 | 98 | // Metatags, optional 99 | // For main page link preview 100 | siteName: 'My Notion Website', 101 | siteDescription: 'Build your own website with Notion. This is a demo site.', 102 | siteImage: 'https://imagehosting.com/images/preview.jpg', 103 | 104 | // URL to custom favicon.ico 105 | siteIcon: 'https://imagehosting.com/images/favicon.ico', 106 | 107 | // Social media links, optional 108 | twitterHandle: '@mytwitter', 109 | 110 | // Additional safety: avoid serving extraneous Notion content from your website 111 | // Use the value from your Notion settings => Workspace => Settings => Domain 112 | notionDomain: 'mydomain', 113 | 114 | // Map slugs (short page names) to Notion page IDs 115 | // Empty slug is your main page 116 | slugToPage: { 117 | '': 'NOTION_PAGE_ID', 118 | about: 'NOTION_PAGE_ID', 119 | contact: 'NOTION_PAGE_ID', 120 | // Hint: you can use '/' in slug name to create subpages 121 | 'about/people': 'NOTION_PAGE_ID', 122 | }, 123 | 124 | // Rewrite meta tags for specific pages 125 | // Use the Notion page ID as the key 126 | pageMetadata: { 127 | 'NOTION_PAGE_ID': { 128 | title: 'My Custom Page Title', 129 | description: 'My custom page description', 130 | image: 'https://imagehosting.com/images/page_preview.jpg', 131 | author: 'My Name', 132 | }, 133 | }, 134 | 135 | // Subdomain redirects are optional 136 | // But it is recommended to have one for www 137 | subDomains: { 138 | www: { 139 | redirect: 'https://yourdomain.com', 140 | }, 141 | }, 142 | 143 | // The 404 (not found) page is optional 144 | // If you don't have one, the default 404 page will be used 145 | fof: { 146 | page: 'NOTION_PAGE_ID', 147 | slug: '404', // default 148 | }, 149 | 150 | // Google Font name, you can choose from https://fonts.google.com 151 | googleFont: 'Roboto', 152 | 153 | // Custom CSS/JS for head and body of a Notion page 154 | customHeadCSS: ` 155 | .notion-topbar { 156 | background: lightblue 157 | }`, 158 | customHeadJS: googleTag(GOOGLE_TAG_ID), 159 | customBodyJS: PAGE_SCRIPT_JS_STRING, 160 | } 161 | ``` 162 | 163 |
164 | 165 | ### Deploy your website 166 | 167 | --- 168 | 169 | ```sh 170 | npm run deploy 171 | ``` 172 | 173 | 🎉 Enjoy your Notion website on your own domain! 🎉 174 | 175 | > [!IMPORTANT] 176 | > You need to run deploy every time you update `page-script.js` or `site-config.ts`. 177 | 178 |
179 | 180 | ### What is build-page-script-js-string.sh? 181 | 182 | --- 183 | 184 | The file `src/page-script.js` contains an example of a page script that you can run on your Notion pages. 185 | This example script removes tooltips from images and hides optional properties in database cards. 186 | 187 | 🔥 This script is run in the web browser! 🔥 188 | 189 | You can use `document`, `window` and all the functionality of a web browser to control the contents and behavior of your Notion pages. 190 | Also, because this is a JS file, you can edit it in your code editor with syntax highlighting and intellisense! 191 | 192 | To incorporate this script into a Notion page, NoteHost must transform the file's contents into a string. Consequently, the `build-page-script-js-string.sh` script is executed whenever you run `npm run deploy`. 193 | 194 | So just add your JS magic to `page-script.js`, run deploy and everything else will happen automagically 😎 195 | 196 |
197 | 198 | ### Logs 199 | 200 | --- 201 | 202 | You can see realtime logs from your website by running 203 | 204 | ```sh 205 | npm run logs 206 | ``` 207 | 208 |
209 | 210 | ### Demo 211 | 212 | --- 213 | 214 | https://www.velsa.net 215 | 216 |
217 | 218 | ### Acknowledgments 219 | 220 | --- 221 | 222 | Based on [Fruition](https://fruitionsite.com), which is no longer maintained 😕 223 | 224 | Lots of thanks to [@DudeThatsErin](https://github.com/DudeThatsErin) and her [code snippet](https://github.com/stephenou/fruitionsite/issues/258#issue-1929516345). 225 | -------------------------------------------------------------------------------- /assets/toggle_moon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /assets/toggle_sun.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /build-body-js-string.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | OUT_FILE=./src/rewriters/_body-js-string.ts 3 | echo 'export const BODY_JS_STRING = `' >$OUT_FILE 4 | cat ./src/rewriters/body.js >>$OUT_FILE 5 | echo '`' >>$OUT_FILE 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "notehost", 3 | "version": "1.0.33", 4 | "description": "NoteHost is a free and powerful Notion hosting service.", 5 | "repository": "https://github.com/velsa/notehost", 6 | "homepage": "https://github.com/velsa/notehost", 7 | "keywords": [ 8 | "Notion", 9 | "Cloudflare", 10 | "Workers", 11 | "Hosting" 12 | ], 13 | "author": { 14 | "name": "Vels (Itzhak Lobak)", 15 | "email": "velshome@gmail.com" 16 | }, 17 | "license": "MIT", 18 | "publishConfig": { 19 | "access": "public" 20 | }, 21 | "type": "module", 22 | "main": "dist/index.js", 23 | "module": "dist/index.cjs", 24 | "types": "dist/index.d.ts", 25 | "bin": { 26 | "init": "./dist/cli/index.js" 27 | }, 28 | "scripts": { 29 | "prebuild": "./build-body-js-string.sh", 30 | "dev:cli": "npx tsx --inspect src/cli/index.ts init test.com", 31 | "build": "npm run prebuild && tsup src/index.ts --format cjs,esm --dts-resolve --clean --sourcemap --out-dir dist && npm run build:cli", 32 | "build:cli": "tsup src/cli/index.ts --format esm --clean --out-dir dist/cli", 33 | "release": "npm run build && changeset publish", 34 | "lint": "echo Fix lint!" 35 | }, 36 | "dependencies": { 37 | "@inquirer/prompts": "^3.3.2", 38 | "chalk": "^5.3.0", 39 | "change-case-all": "^2.1.0", 40 | "commander": "^11.1.0", 41 | "ejs": "^3.1.9", 42 | "htmlrewriter": "^0.0.7", 43 | "is-valid-domain": "^0.1.6" 44 | }, 45 | "devDependencies": { 46 | "@changesets/cli": "^2.27.1", 47 | "@cloudflare/workers-types": "^4.20231218.0", 48 | "@stylistic/eslint-plugin": "^1.5.4", 49 | "@types/node": "^20.11.6", 50 | "@typescript-eslint/eslint-plugin": "^6.19.0", 51 | "@typescript-eslint/parser": "^6.19.0", 52 | "eslint-plugin-import": "^2.29.1", 53 | "prettier": "3.2.2", 54 | "tsc": "^2.0.4", 55 | "tsup": "^8.0.1", 56 | "typescript": "^5.3.3", 57 | "wrangler": "^3.0.0" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/append-js-pf.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { HTMLRewriter } from 'htmlrewriter' 3 | import { NoteHostSiteConfigFull } from '.' 4 | import { BodyRewriter, HeadRewriter, MetaRewriter } from './rewriters' 5 | 6 | export async function appendJavascriptPolyfill(res: Response, url: URL, config: NoteHostSiteConfigFull) { 7 | // eslint-disable-next-line no-undef 8 | return new HTMLRewriter() 9 | .on('title', new MetaRewriter(config, url) as any) 10 | .on('meta', new MetaRewriter(config, url) as any) 11 | .on('head', new HeadRewriter(config) as any) 12 | .on('body', new BodyRewriter(config) as any) 13 | .transform(res) 14 | } 15 | -------------------------------------------------------------------------------- /src/append-js.ts: -------------------------------------------------------------------------------- 1 | import { MetaRewriter, HeadRewriter, BodyRewriter } from './rewriters' 2 | import { NoteHostSiteConfigFull } from './types' 3 | 4 | export async function appendJavascript(res: Response, url: URL, config: NoteHostSiteConfigFull) { 5 | // eslint-disable-next-line no-undef 6 | return new HTMLRewriter() 7 | .on('title', new MetaRewriter(config, url)) 8 | .on('meta', new MetaRewriter(config, url)) 9 | .on('head', new HeadRewriter(config)) 10 | .on('body', new BodyRewriter(config)) 11 | .transform(res) 12 | } 13 | -------------------------------------------------------------------------------- /src/cli/copy-files.ts: -------------------------------------------------------------------------------- 1 | import * as ejs from 'ejs' 2 | import fs from 'fs' 3 | import path from 'path' 4 | import { ParserConfig } from './parser-config' 5 | 6 | interface CopyFilesToSDKParams { 7 | parserConfig: ParserConfig 8 | originDir: string 9 | sdkDir: string 10 | } 11 | 12 | export async function copyFilesToSDK({ parserConfig, originDir, sdkDir }: CopyFilesToSDKParams) { 13 | const files = fs.readdirSync(originDir) 14 | 15 | fs.mkdirSync(sdkDir, { recursive: true }) 16 | 17 | files.forEach((file) => { 18 | const destFile = file === '_gitignore' ? '.gitignore' : file 19 | const filePath = path.join(originDir, file) 20 | 21 | if (isDirectory(filePath)) { 22 | copyFilesToSDK({ 23 | parserConfig, 24 | originDir: path.join(originDir, file), 25 | sdkDir: path.join(sdkDir, file), 26 | }) 27 | } else { 28 | try { 29 | const templateFile = fs.readFileSync(filePath, 'utf8') 30 | const renderedFile = ejs.render(templateFile, parserConfig) 31 | const sdkFilePath = path.join(sdkDir, destFile) 32 | const fileExt = path.extname(sdkFilePath) 33 | 34 | fs.writeFileSync(sdkFilePath, renderedFile, { 35 | mode: fileExt === '.sh' ? 0o755 : 0o644, 36 | }) 37 | } catch (e) { 38 | console.error('Error generating SDK file: ', filePath) 39 | console.error(e) 40 | process.exit(1) 41 | } 42 | } 43 | }) 44 | } 45 | 46 | function isDirectory(path: string) { 47 | try { 48 | return fs.lstatSync(path).isDirectory() 49 | } catch (e) { 50 | return false 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/cli/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { program } from 'commander' 3 | import { version } from '../../package.json' 4 | import { initRepo } from './init-repo' 5 | 6 | program 7 | .name('notehost') 8 | .description('NoteHost CLI: Deploy and manage Notion websites via Cloudflare workers.') 9 | .version(version) 10 | 11 | program 12 | .command('init') 13 | .description('Initialize a new NoteHost worker repo') 14 | .argument('', 'domain name') 15 | .action(initRepo) 16 | 17 | program.parse() 18 | -------------------------------------------------------------------------------- /src/cli/init-repo.ts: -------------------------------------------------------------------------------- 1 | import { confirm, select } from '@inquirer/prompts' 2 | import chalk from 'chalk' 3 | import fs from 'fs' 4 | import path from 'path' 5 | import { copyFilesToSDK } from './copy-files' 6 | import { getParserConfig } from './parser-config' 7 | 8 | export async function initRepo(domain) { 9 | const sdkDir = path.join(process.cwd(), domain) 10 | const originDir = buildOriginDir(process.argv[1]) 11 | const parserConfig = await getParserConfig(domain) 12 | const templates = fs.readdirSync(path.join(originDir, 'templates')) 13 | const template = 14 | templates.length > 1 15 | ? await select({ 16 | message: 'Generate from template:', 17 | choices: templates.map((t) => ({ name: t, value: t })), 18 | }) 19 | : templates[0] 20 | 21 | console.log(`\n🎬 Ready to generate NoteHost worker in: ${sdkDir}`) 22 | await confirm({ message: 'Continue?', default: true }) 23 | 24 | console.log('Generating...') 25 | 26 | copyFilesToSDK({ 27 | parserConfig, 28 | originDir: path.join(originDir, 'templates', template), 29 | sdkDir, 30 | }) 31 | 32 | console.log(`\n🎉 Done! Your worker is in`, sdkDir) 33 | console.log(`\nGo into this directory and run ${chalk.bold('npm install')}`) 34 | console.log(`Edit ${chalk.bold('src/site-config.ts')} to setup your website.`) 35 | console.log(`Review ${chalk.bold('wrangler.toml')} and make sure your worker name is correct.`) 36 | console.log(`And finally run ${chalk.bold('npm run deploy')} to publish your website.`) 37 | 38 | process.exit(0) 39 | } 40 | 41 | function buildOriginDir(appPath: string) { 42 | const runDir = path.parse(appPath).dir 43 | const parts = runDir.split(path.sep) 44 | const last = parts[parts.length - 1] 45 | const beforeLast = parts[parts.length - 2] 46 | 47 | // console.error('appPath', appPath) 48 | // console.error('runDir', runDir) 49 | // console.error('parts', parts) 50 | 51 | // running locally 52 | if (process.env.NOTEHOST_CLI_DEBUG) { 53 | return path.join(runDir, '..', '..') 54 | } 55 | 56 | if (last === '.bin' && beforeLast === 'node_modules') { 57 | // npx (installed) 58 | return path.join(runDir, '..', 'notehost') 59 | } 60 | 61 | if (last === 'cli' && beforeLast === 'dist') { 62 | // npx (tmp) 63 | return path.join(runDir, '..', '..') 64 | } 65 | 66 | if (beforeLast === 'notehost') { 67 | // pnpx 68 | return path.join(runDir, '..') 69 | } 70 | 71 | // npx 72 | return path.join(runDir, '..', 'notehost') 73 | } 74 | -------------------------------------------------------------------------------- /src/cli/parser-config.ts: -------------------------------------------------------------------------------- 1 | import * as changeCase from 'change-case-all' 2 | import { version } from '../../package.json' 3 | import { SiteConfig, getSiteConfigFromUser } from './site-config' 4 | 5 | export interface ParserConfig extends SiteConfig { 6 | packageJsonName: string 7 | wranglerWorkerName: string 8 | 9 | notehostVersion: string 10 | } 11 | 12 | export async function getParserConfig(domainName: string | undefined): Promise { 13 | const { mainPageId, siteName, siteDescription, siteImage } = await getSiteConfigFromUser(domainName) 14 | const kebabDomain = changeCase.kebabCase(domainName) 15 | 16 | return { 17 | domainName, 18 | mainPageId, 19 | siteName, 20 | siteDescription, 21 | siteImage, 22 | packageJsonName: kebabDomain, 23 | wranglerWorkerName: `${kebabDomain}-notion-proxy`, 24 | notehostVersion: version, 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/cli/site-config.ts: -------------------------------------------------------------------------------- 1 | import { input } from '@inquirer/prompts' 2 | import { validDomainName } from './validators' 3 | 4 | export interface SiteConfig { 5 | domainName: string 6 | mainPageId: string 7 | siteName: string 8 | siteDescription: string 9 | siteImage: string 10 | } 11 | 12 | export async function getSiteConfigFromUser(domain: string): Promise { 13 | const siteConfig = { domainName: domain } as SiteConfig 14 | 15 | do { 16 | siteConfig.domainName = await input({ 17 | message: 'Please confirm the domain name:', 18 | default: siteConfig.domainName, 19 | }) 20 | 21 | if (!validDomainName(siteConfig.domainName)) { 22 | console.error('Invalid domain name:', siteConfig.domainName) 23 | } 24 | } while (!validDomainName(siteConfig.domainName)) 25 | 26 | console.log( 27 | '\n🏷️ Metadata details.' + 28 | '\nYou can skip those fields now (press Enter) and fill them later via the config file.\n', 29 | ) 30 | 31 | siteConfig.mainPageId = await input({ 32 | message: 'ID of your Notion Page:', 33 | }) 34 | siteConfig.siteName = await input({ message: 'Your site name:' }) 35 | siteConfig.siteDescription = await input({ 36 | message: 'Your site description:', 37 | }) 38 | siteConfig.siteImage = await input({ 39 | message: 'Link preview image URL:', 40 | }) 41 | 42 | return siteConfig 43 | } 44 | -------------------------------------------------------------------------------- /src/cli/validators.ts: -------------------------------------------------------------------------------- 1 | import isValidDomain from 'is-valid-domain' 2 | 3 | export function validDomainName(domain: string) { 4 | return ( 5 | domain && 6 | domain.length > 0 && 7 | isValidDomain(domain, { 8 | wildcard: false, 9 | }) 10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /src/config-helpers.ts: -------------------------------------------------------------------------------- 1 | export function googleTag(googleTagId: string) { 2 | return ` 3 | 4 | 5 | 12 | ` 13 | } 14 | -------------------------------------------------------------------------------- /src/handlers/handle-api.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | export async function handleApi(url: URL, request: Request) { 3 | // Forward API 4 | const response = await fetch(url.toString(), { 5 | body: url.pathname.startsWith('/api/v3/getPublicPageData') ? null : request.body, 6 | headers: { 7 | 'content-type': 'application/json;charset=UTF-8', 8 | 'user-agent': 9 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.163 Safari/537.36', 10 | }, 11 | method: 'POST', 12 | }) 13 | const ret = new Response(response.body as BodyInit, response) 14 | 15 | ret.headers.set('Access-Control-Allow-Origin', '*') 16 | 17 | return ret 18 | } 19 | -------------------------------------------------------------------------------- /src/handlers/handle-app-js.ts: -------------------------------------------------------------------------------- 1 | import { NoteHostSiteConfigFull } from '../types' 2 | 3 | export async function handleAppJs(url: URL, siteConfig: NoteHostSiteConfigFull) { 4 | const { domain } = siteConfig 5 | const response = await fetch(url.toString()) 6 | const body = await response.text() 7 | const siteRegex = new RegExp( 8 | siteConfig.notionDomain ? `${siteConfig.notionDomain}.notion.site` : 'www.notion.so', 9 | 'g', 10 | ) 11 | const ret = new Response(body.replace(siteRegex, domain).replace(/notion.so/g, domain), response) 12 | 13 | ret.headers.set('Content-Type', 'application/x-javascript') 14 | 15 | return ret 16 | } 17 | -------------------------------------------------------------------------------- /src/handlers/handle-favicon.ts: -------------------------------------------------------------------------------- 1 | export async function handleFavicon(url: URL, siteIcon: string) { 2 | const response = await fetch(siteIcon) 3 | const body = await response.arrayBuffer() 4 | const ret = new Response(body, response) 5 | 6 | return ret 7 | } 8 | -------------------------------------------------------------------------------- /src/handlers/handle-js.ts: -------------------------------------------------------------------------------- 1 | export async function handleJs(url: URL) { 2 | const response = await fetch(url.toString()) 3 | const body = await response.text() 4 | const ret = new Response(body, response) 5 | 6 | ret.headers.set('Content-Type', 'application/x-javascript') 7 | 8 | return ret 9 | } 10 | -------------------------------------------------------------------------------- /src/handlers/handle-options.ts: -------------------------------------------------------------------------------- 1 | const corsHeaders = { 2 | 'Access-Control-Allow-Origin': '*', 3 | 'Access-Control-Allow-Methods': 'GET, HEAD, POST, PUT, OPTIONS', 4 | 'Access-Control-Allow-Headers': 'Content-Type', 5 | } 6 | 7 | export function handleOptions(request: Request) { 8 | if ( 9 | request.headers.get('Origin') !== null && 10 | request.headers.get('Access-Control-Request-Method') !== null && 11 | request.headers.get('Access-Control-Request-Headers') !== null 12 | ) { 13 | // Handle CORS pre-flight request. 14 | return new Response(null, { 15 | headers: corsHeaders, 16 | }) 17 | } 18 | 19 | // Handle standard OPTIONS request. 20 | return new Response(null, { 21 | headers: { 22 | Allow: 'GET, HEAD, POST, PUT, OPTIONS', 23 | }, 24 | }) 25 | } 26 | -------------------------------------------------------------------------------- /src/handlers/handle-other.ts: -------------------------------------------------------------------------------- 1 | export async function handleNotionAsset(url: URL) { 2 | const response = await fetch(url.toString()) 3 | const body = await response.arrayBuffer() 4 | const ret = new Response(body, response) 5 | 6 | return ret 7 | } 8 | -------------------------------------------------------------------------------- /src/handlers/handle-sitemap.ts: -------------------------------------------------------------------------------- 1 | import { NoteHostSiteConfigFull } from '../types' 2 | 3 | export function handleSitemap(request: Request, siteConfig: NoteHostSiteConfigFull) { 4 | const { domain, slugs } = siteConfig 5 | let sitemap = '' 6 | 7 | slugs.forEach((slug) => { 8 | sitemap += `https://${domain}/${slug}` 9 | }) 10 | sitemap += '' 11 | 12 | const response = new Response(sitemap) 13 | 14 | response.headers.set('content-type', 'application/xml') 15 | 16 | return response 17 | } 18 | -------------------------------------------------------------------------------- /src/handlers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './handle-api' 2 | export * from './handle-app-js' 3 | export * from './handle-js' 4 | export * from './handle-options' 5 | export * from './handle-sitemap' 6 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './config-helpers' 2 | export * from './reverse-proxy' 3 | export { initializeReverseProxy } from './reverse-proxy-init' 4 | export * from './types' 5 | -------------------------------------------------------------------------------- /src/reverse-proxy-init.ts: -------------------------------------------------------------------------------- 1 | import { NoteHostSiteConfig, NoteHostSiteConfigFull } from './types' 2 | 3 | export let siteConfig: NoteHostSiteConfigFull = {} as NoteHostSiteConfigFull 4 | 5 | export function initializeReverseProxy(siteConfigUser: NoteHostSiteConfig) { 6 | siteConfig = { 7 | ...siteConfigUser, 8 | slugs: [], 9 | pages: [], 10 | pageToSlug: {}, 11 | } 12 | 13 | siteConfig.pageMetadata = siteConfig.pageMetadata || {} 14 | 15 | siteConfig.fof = { 16 | page: siteConfig.fof?.page, 17 | slug: siteConfig.fof?.slug || '404', 18 | } 19 | 20 | if (siteConfig.fof.page?.length) { 21 | siteConfig.slugToPage[siteConfig.fof.slug] = siteConfig.fof.page 22 | } 23 | 24 | // Build helper indexes for worker and for the client (body.js) 25 | Object.keys(siteConfig.slugToPage).forEach((slug) => { 26 | const pageId = siteConfig.slugToPage[slug] 27 | 28 | siteConfig.slugs.push(slug) 29 | siteConfig.pages.push(pageId) 30 | siteConfig.pageToSlug[pageId] = slug 31 | }) 32 | } 33 | -------------------------------------------------------------------------------- /src/reverse-proxy.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | import { appendJavascript } from './append-js' 3 | import { appendJavascriptPolyfill } from './append-js-pf' 4 | import { handleFavicon } from './handlers/handle-favicon' 5 | import { handleNotionAsset } from './handlers/handle-other' 6 | import { handleApi, handleAppJs, handleJs, handleOptions, handleSitemap } from './handlers/index' 7 | import { siteConfig } from './reverse-proxy-init' 8 | 9 | export async function reverseProxy( 10 | request: Request, 11 | opts: { 12 | cloudflare: boolean 13 | } = { cloudflare: true }, 14 | ) { 15 | const { cloudflare } = opts 16 | 17 | if (!siteConfig) { 18 | throw new Error('Site config is not initialized. Please call initializeReverseProxy() first.') 19 | } 20 | 21 | const { domain, slugToPage, siteIcon } = siteConfig 22 | 23 | if (request.method === 'OPTIONS') { 24 | return handleOptions(request) 25 | } 26 | 27 | const url = new URL(request.url) 28 | const subDomain = url.hostname.split('.')[0] 29 | 30 | if (url.hostname === domain) { 31 | url.hostname = siteConfig.notionDomain ? `${siteConfig.notionDomain}.notion.site` : 'www.notion.so' 32 | 33 | // Handle special Notion routes 34 | if (url.pathname === '/robots.txt') { 35 | return new Response(`Sitemap: https://${domain}/sitemap.xml`) 36 | } 37 | 38 | if (url.pathname === '/sitemap.xml') { 39 | return handleSitemap(request, siteConfig) 40 | } 41 | 42 | if (url.pathname.startsWith('/app') && url.pathname.endsWith('js')) { 43 | return handleAppJs(url, siteConfig) 44 | } 45 | 46 | if (url.pathname.startsWith('/api')) { 47 | return handleApi(url, request) 48 | } 49 | 50 | if (url.pathname.endsWith('.js')) { 51 | return handleJs(url) 52 | } 53 | 54 | if (url.pathname.endsWith('favicon.ico') && siteIcon) { 55 | return handleFavicon(url, siteIcon) 56 | } 57 | 58 | if ( 59 | url.pathname.startsWith('/_assets') || 60 | url.pathname.startsWith('/image') || 61 | url.pathname.startsWith('/f/refresh') || 62 | url.pathname.match(/\.[a-zA-Z]{2,4}$/) 63 | ) { 64 | return handleNotionAsset(url) 65 | } 66 | 67 | // Handle slugs, from site-config and from KV 68 | console.log('url.pathname', url.pathname) 69 | 70 | const slug = url.pathname.split('/').pop() 71 | const slugHash = url.pathname.slice(-32) 72 | const page = slugToPage[slug] 73 | 74 | if (page) { 75 | console.log(`Redirecting ${slug} to https://${domain}/${page}`) 76 | 77 | return Response.redirect(`https://${domain}/${page}`, 301) 78 | } else if (slugHash && slugHash !== slug && slugHash.length === 32) { 79 | console.log(`Redirecting ${slug} to https://${domain}/${slugHash}`) 80 | 81 | return Response.redirect(`https://${domain}/${slugHash}`, 301) 82 | } else if (slug && slug.length !== 32) { 83 | if (siteConfig.fof?.page?.length) { 84 | return Response.redirect(`https://${domain}/${siteConfig.fof.page}`, 301) 85 | } else { 86 | console.error('!! Page Not found (404)', url.pathname) 87 | 88 | return new Response('NoteHost: Page Not found (404).', { status: 404 }) 89 | } 90 | } 91 | } else if (subDomain && siteConfig.subDomains) { 92 | const sub = siteConfig.subDomains[subDomain] 93 | 94 | if (sub) { 95 | // console.log(`Redirecting ${url.hostname} to ${sub.redirect}`) 96 | 97 | return Response.redirect(sub.redirect, 301) 98 | } 99 | } 100 | 101 | const response = await fetch(url.toString(), { 102 | body: request.body, 103 | headers: request.headers, 104 | method: request.method, 105 | }) 106 | const ret = new Response(response.body as BodyInit, response) 107 | 108 | ret.headers.delete('Content-Security-Policy') 109 | ret.headers.delete('X-Content-Security-Policy') 110 | 111 | return cloudflare ? appendJavascript(ret, url, siteConfig) : appendJavascriptPolyfill(ret, url, siteConfig) 112 | } 113 | -------------------------------------------------------------------------------- /src/rewriters/_body-js-string.ts: -------------------------------------------------------------------------------- 1 | export const BODY_JS_STRING = ` 2 | /* eslint-disable no-unused-vars */ 3 | /* eslint-disable prefer-rest-params */ 4 | /* eslint-disable no-lonely-if */ 5 | /* eslint-disable no-restricted-globals */ 6 | /* eslint-disable no-underscore-dangle */ 7 | /* eslint-disable no-undef */ 8 | localStorage.__console = true 9 | 10 | const el = document.createElement('div') 11 | let redirected = false 12 | 13 | function getPage() { 14 | return location.pathname.slice(-32) 15 | } 16 | 17 | function getSlug() { 18 | return location.pathname.slice(1) 19 | } 20 | 21 | function updateSlug() { 22 | const slug = PAGE_TO_SLUG[getPage()] 23 | 24 | if (slug != null) { 25 | history.replaceState(history.state, '', ['/', slug].join('')) 26 | } 27 | } 28 | 29 | function enableConsoleEffectAndSetMode(mode) { 30 | if (__console && !__console.isEnabled) { 31 | __console.enable() 32 | window.location.reload() 33 | } else { 34 | __console?.environment?.ThemeStore?.setState({ mode }) 35 | localStorage.setItem('newTheme', JSON.stringify({ mode })) 36 | } 37 | } 38 | 39 | function onDark() { 40 | el.innerHTML = 41 | '
' 42 | document.body.classList.add('dark') 43 | enableConsoleEffectAndSetMode('dark') 44 | } 45 | 46 | function onLight() { 47 | el.innerHTML = 48 | '
' 49 | document.body.classList.remove('dark') 50 | enableConsoleEffectAndSetMode('light') 51 | } 52 | 53 | function toggle() { 54 | if (document.body.classList.contains('dark')) { 55 | onLight() 56 | } else { 57 | onDark() 58 | } 59 | } 60 | 61 | function addDarkModeButton(device) { 62 | const nav = 63 | device === 'web' 64 | ? document.querySelector('.notion-topbar').firstChild 65 | : document.querySelector('.notion-topbar-mobile') 66 | 67 | el.className = 'toggle-mode' 68 | el.addEventListener('click', toggle) 69 | 70 | const timeout = device === 'web' ? 0 : 500 71 | 72 | setTimeout(() => { 73 | nav.appendChild(el) 74 | }, timeout) 75 | 76 | // get the current theme and add the toggle to represent that theme 77 | const currentTheme = JSON.parse(localStorage.getItem('newTheme'))?.mode 78 | 79 | if (currentTheme) { 80 | if (currentTheme === 'dark') { 81 | onDark() 82 | } else { 83 | onLight() 84 | } 85 | } else { 86 | // enable smart dark mode based on user-preference 87 | if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { 88 | onDark() 89 | } else { 90 | onLight() 91 | } 92 | } 93 | 94 | // try to detect if user-preference change 95 | window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { 96 | toggle() 97 | }) 98 | } 99 | 100 | const observer = new MutationObserver(() => { 101 | if (redirected) return 102 | 103 | const nav = document.querySelector('.notion-topbar') 104 | const mobileNav = document.querySelector('.notion-topbar-mobile') 105 | 106 | if ((nav && nav.firstChild && nav.firstChild.firstChild) || (mobileNav && mobileNav.firstChild)) { 107 | // console.log('redirected', getSlug()) 108 | updateSlug() 109 | redirected = true 110 | 111 | addDarkModeButton(nav ? 'web' : 'mobile') 112 | 113 | const { onpopstate } = window 114 | 115 | window.onpopstate = function () { 116 | // console.log('onpopstate'); 117 | if (slugs.includes(getSlug())) { 118 | const page = SLUG_TO_PAGE[getSlug()] 119 | 120 | if (page) { 121 | // console.log('slug:', getSlug()) 122 | // console.log('redirecting to:', page) 123 | history.replaceState(history.state, 'bypass', ['/', page].join('')) 124 | } 125 | } 126 | 127 | onpopstate.apply(this, [].slice.call(arguments)) 128 | updateSlug() 129 | } 130 | } 131 | }) 132 | 133 | observer.observe(document.querySelector('#notion-app'), { 134 | childList: true, 135 | subtree: true, 136 | }) 137 | 138 | const { replaceState, back, forward } = window.history 139 | 140 | window.history.back = function () { 141 | back.apply(window.history, arguments) 142 | } 143 | 144 | window.history.forward = function () { 145 | forward.apply(window.history, arguments) 146 | } 147 | 148 | window.history.replaceState = function () { 149 | if (arguments[1] === 'bypass') { 150 | return 151 | } 152 | 153 | const slug = getSlug() 154 | const isKnownSlug = slugs.includes(slug) 155 | 156 | console.log('replaceState:', { slug, isKnownSlug, arguments }) 157 | 158 | // console.log('replaceState arguments:', arguments) 159 | // console.log('replaceState state:', state) 160 | 161 | if (arguments[2] === '/login') { 162 | const page = SLUG_TO_PAGE[slug] 163 | 164 | if (page) { 165 | // console.log('slug:', slug) 166 | // console.log('redirecting to:', page) 167 | arguments[2] = ['/', page].join('') 168 | replaceState.apply(window.history, arguments) 169 | window.location.reload() 170 | 171 | return 172 | } 173 | } else { 174 | if (isKnownSlug && arguments[2] !== ['/', slug].join('')) { 175 | return 176 | } 177 | } 178 | 179 | replaceState.apply(window.history, arguments) 180 | } 181 | 182 | const { pushState } = window.history 183 | 184 | window.history.pushState = function () { 185 | const dest = new URL(location.protocol + location.host + arguments[2]) 186 | const id = dest.pathname.slice(-32) 187 | 188 | // console.log('pushState state:', state) 189 | // console.log('pushState id:', id) 190 | if (pages.includes(id)) { 191 | arguments[2] = ['/', PAGE_TO_SLUG[id]].join('') 192 | } 193 | 194 | return pushState.apply(window.history, arguments) 195 | } 196 | 197 | const { open } = window.XMLHttpRequest.prototype 198 | 199 | window.XMLHttpRequest.prototype.open = function () { 200 | arguments[1] = arguments[1].replace(domain, notionDomain) 201 | 202 | if (arguments[1].indexOf('msgstore.' + notionDomain) > -1) { 203 | return 204 | } 205 | 206 | // console.log('XMLHttpRequest.open arguments:', arguments) 207 | open.apply(this, [].slice.call(arguments)) 208 | } 209 | ` 210 | -------------------------------------------------------------------------------- /src/rewriters/body-rewriter.ts: -------------------------------------------------------------------------------- 1 | import { NoteHostSiteConfigFull } from '../types' 2 | import { BODY_JS_STRING } from './_body-js-string' 3 | /* eslint-disable class-methods-use-this */ 4 | export class BodyRewriter { 5 | siteConfig: NoteHostSiteConfigFull 6 | 7 | constructor(siteConfig: NoteHostSiteConfigFull) { 8 | this.siteConfig = siteConfig 9 | } 10 | 11 | element(element: Element) { 12 | const { domain, slugToPage, pageToSlug, slugs, pages, customBodyJS } = this.siteConfig 13 | 14 | element.append( 15 | ` 16 | 26 | ${customBodyJS ?? ''} 27 | `, 28 | { 29 | html: true, 30 | }, 31 | ) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/rewriters/body.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-vars */ 2 | /* eslint-disable prefer-rest-params */ 3 | /* eslint-disable no-lonely-if */ 4 | /* eslint-disable no-restricted-globals */ 5 | /* eslint-disable no-underscore-dangle */ 6 | /* eslint-disable no-undef */ 7 | localStorage.__console = true 8 | 9 | const el = document.createElement('div') 10 | let redirected = false 11 | 12 | function getPage() { 13 | return location.pathname.slice(-32) 14 | } 15 | 16 | function getSlug() { 17 | return location.pathname.slice(1) 18 | } 19 | 20 | function updateSlug() { 21 | const slug = PAGE_TO_SLUG[getPage()] 22 | 23 | if (slug != null) { 24 | history.replaceState(history.state, '', ['/', slug].join('')) 25 | } 26 | } 27 | 28 | function enableConsoleEffectAndSetMode(mode) { 29 | if (__console && !__console.isEnabled) { 30 | __console.enable() 31 | window.location.reload() 32 | } else { 33 | __console?.environment?.ThemeStore?.setState({ mode }) 34 | localStorage.setItem('newTheme', JSON.stringify({ mode })) 35 | } 36 | } 37 | 38 | function onDark() { 39 | el.innerHTML = 40 | '
' 41 | document.body.classList.add('dark') 42 | enableConsoleEffectAndSetMode('dark') 43 | } 44 | 45 | function onLight() { 46 | el.innerHTML = 47 | '
' 48 | document.body.classList.remove('dark') 49 | enableConsoleEffectAndSetMode('light') 50 | } 51 | 52 | function toggle() { 53 | if (document.body.classList.contains('dark')) { 54 | onLight() 55 | } else { 56 | onDark() 57 | } 58 | } 59 | 60 | function addDarkModeButton(device) { 61 | const nav = 62 | device === 'web' 63 | ? document.querySelector('.notion-topbar').firstChild 64 | : document.querySelector('.notion-topbar-mobile') 65 | 66 | el.className = 'toggle-mode' 67 | el.addEventListener('click', toggle) 68 | 69 | const timeout = device === 'web' ? 0 : 500 70 | 71 | setTimeout(() => { 72 | nav.appendChild(el) 73 | }, timeout) 74 | 75 | // get the current theme and add the toggle to represent that theme 76 | const currentTheme = JSON.parse(localStorage.getItem('newTheme'))?.mode 77 | 78 | if (currentTheme) { 79 | if (currentTheme === 'dark') { 80 | onDark() 81 | } else { 82 | onLight() 83 | } 84 | } else { 85 | // enable smart dark mode based on user-preference 86 | if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { 87 | onDark() 88 | } else { 89 | onLight() 90 | } 91 | } 92 | 93 | // try to detect if user-preference change 94 | window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { 95 | toggle() 96 | }) 97 | } 98 | 99 | const observer = new MutationObserver(() => { 100 | if (redirected) return 101 | 102 | const nav = document.querySelector('.notion-topbar') 103 | const mobileNav = document.querySelector('.notion-topbar-mobile') 104 | 105 | if ((nav && nav.firstChild && nav.firstChild.firstChild) || (mobileNav && mobileNav.firstChild)) { 106 | // console.log('redirected', getSlug()) 107 | updateSlug() 108 | redirected = true 109 | 110 | addDarkModeButton(nav ? 'web' : 'mobile') 111 | 112 | const { onpopstate } = window 113 | 114 | window.onpopstate = function () { 115 | // console.log('onpopstate'); 116 | if (slugs.includes(getSlug())) { 117 | const page = SLUG_TO_PAGE[getSlug()] 118 | 119 | if (page) { 120 | // console.log('slug:', getSlug()) 121 | // console.log('redirecting to:', page) 122 | history.replaceState(history.state, 'bypass', ['/', page].join('')) 123 | } 124 | } 125 | 126 | onpopstate.apply(this, [].slice.call(arguments)) 127 | updateSlug() 128 | } 129 | } 130 | }) 131 | 132 | observer.observe(document.querySelector('#notion-app'), { 133 | childList: true, 134 | subtree: true, 135 | }) 136 | 137 | const { replaceState, back, forward } = window.history 138 | 139 | window.history.back = function () { 140 | back.apply(window.history, arguments) 141 | } 142 | 143 | window.history.forward = function () { 144 | forward.apply(window.history, arguments) 145 | } 146 | 147 | window.history.replaceState = function () { 148 | if (arguments[1] === 'bypass') { 149 | return 150 | } 151 | 152 | const slug = getSlug() 153 | const isKnownSlug = slugs.includes(slug) 154 | 155 | console.log('replaceState:', { slug, isKnownSlug, arguments }) 156 | 157 | // console.log('replaceState arguments:', arguments) 158 | // console.log('replaceState state:', state) 159 | 160 | if (arguments[2] === '/login') { 161 | const page = SLUG_TO_PAGE[slug] 162 | 163 | if (page) { 164 | // console.log('slug:', slug) 165 | // console.log('redirecting to:', page) 166 | arguments[2] = ['/', page].join('') 167 | replaceState.apply(window.history, arguments) 168 | window.location.reload() 169 | 170 | return 171 | } 172 | } else { 173 | if (isKnownSlug && arguments[2] !== ['/', slug].join('')) { 174 | return 175 | } 176 | } 177 | 178 | replaceState.apply(window.history, arguments) 179 | } 180 | 181 | const { pushState } = window.history 182 | 183 | window.history.pushState = function () { 184 | const dest = new URL(location.protocol + location.host + arguments[2]) 185 | const id = dest.pathname.slice(-32) 186 | 187 | // console.log('pushState state:', state) 188 | // console.log('pushState id:', id) 189 | if (pages.includes(id)) { 190 | arguments[2] = ['/', PAGE_TO_SLUG[id]].join('') 191 | } 192 | 193 | return pushState.apply(window.history, arguments) 194 | } 195 | 196 | const { open } = window.XMLHttpRequest.prototype 197 | 198 | window.XMLHttpRequest.prototype.open = function () { 199 | arguments[1] = arguments[1].replace(domain, notionDomain) 200 | 201 | if (arguments[1].indexOf('msgstore.' + notionDomain) > -1) { 202 | return 203 | } 204 | 205 | // console.log('XMLHttpRequest.open arguments:', arguments) 206 | open.apply(this, [].slice.call(arguments)) 207 | } 208 | -------------------------------------------------------------------------------- /src/rewriters/element-handler.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable class-methods-use-this */ 2 | import { NoteHostSiteConfigFull } from '../types' 3 | 4 | export interface HandleRule { 5 | attribute: string 6 | match: string 7 | action: { 8 | type: 'remove' | 'replace' 9 | value?: string 10 | } 11 | } 12 | 13 | export class ElementHandler { 14 | siteConfig: NoteHostSiteConfigFull 15 | 16 | handleRules: HandleRule[] 17 | 18 | constructor(siteConfig: NoteHostSiteConfigFull, handleRules: HandleRule[] = []) { 19 | this.siteConfig = siteConfig 20 | this.handleRules = handleRules 21 | } 22 | 23 | element(element: Element) { 24 | // console.log(`Incoming element: <${element.tagName}>${element.getAttribute('content')}`) 25 | 26 | for (const rule of this.handleRules) { 27 | const attribute = element.getAttribute(rule.attribute) ?? '' 28 | 29 | if (attribute.match(rule.match)) { 30 | switch (rule.action.type) { 31 | case 'remove': 32 | element.remove() 33 | break 34 | case 'replace': 35 | element.setAttribute(rule.attribute, rule.action.value ?? '') 36 | break 37 | default: 38 | console.error(`Unknown action type in rule: ${JSON.stringify(rule, null, 2)}`) 39 | break 40 | } 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/rewriters/head-rewriter.ts: -------------------------------------------------------------------------------- 1 | import { NoteHostSiteConfigFull } from '../types' 2 | 3 | /* eslint-disable class-methods-use-this */ 4 | export class HeadRewriter { 5 | siteConfig: NoteHostSiteConfigFull 6 | 7 | constructor(siteConfig: NoteHostSiteConfigFull) { 8 | this.siteConfig = siteConfig 9 | } 10 | 11 | element(element: Element) { 12 | const { googleFont, customHeadJS, customHeadCSS } = this.siteConfig 13 | 14 | if (googleFont) { 15 | element.append( 16 | ` 20 | `, 21 | { 22 | html: true, 23 | }, 24 | ) 25 | } 26 | 27 | element.append( 28 | ` 42 | ${customHeadJS ?? ''}`, 43 | { 44 | html: true, 45 | }, 46 | ) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/rewriters/index.ts: -------------------------------------------------------------------------------- 1 | export * from './body-rewriter' 2 | export * from './head-rewriter' 3 | export * from './meta-rewriter' 4 | -------------------------------------------------------------------------------- /src/rewriters/meta-rewriter.ts: -------------------------------------------------------------------------------- 1 | import { NoteHostSiteConfigFull } from '../types' 2 | 3 | /* eslint-disable class-methods-use-this */ 4 | export class MetaRewriter { 5 | siteConfig: NoteHostSiteConfigFull 6 | 7 | url: URL 8 | 9 | isRootPage: boolean 10 | 11 | constructor(siteConfig: NoteHostSiteConfigFull, url: URL) { 12 | this.siteConfig = siteConfig 13 | this.url = url 14 | this.isRootPage = this.siteConfig.pageToSlug[this.url.pathname.slice(1)] === '' 15 | } 16 | 17 | element(element: Element) { 18 | const { siteName, siteDescription, twitterHandle, siteImage, domain, pageToSlug, pageMetadata } = this.siteConfig 19 | const page = this.url.pathname.slice(-32) 20 | const property = element.getAttribute('property') ?? '' 21 | const name = element.getAttribute('name') ?? '' 22 | const content = element.getAttribute('content') ?? '' 23 | 24 | // console.log( 25 | // `${this.url}: <${element.tagName} name="${name}" property="${property}">${content}`, 26 | // ) 27 | 28 | if (element.tagName === 'title') { 29 | const pageTitle = pageMetadata[page]?.title ?? removeNotionAds(content) 30 | 31 | element.setInnerContent(pageTitle) 32 | } 33 | 34 | if (property === 'og:title' || name === 'twitter:title') { 35 | const pageTitle = pageMetadata[page]?.title ?? removeNotionAds(content) 36 | 37 | element.setAttribute('content', pageTitle) 38 | } 39 | 40 | if (property === 'og:site_name') { 41 | element.setAttribute('content', siteName) 42 | } 43 | 44 | if (name === 'article:author') { 45 | const pageAuthor = pageMetadata[page]?.author ?? content 46 | 47 | element.setAttribute('content', pageAuthor) 48 | } 49 | 50 | if (name === 'description' || property === 'og:description' || name === 'twitter:description') { 51 | if (this.isRootPage) { 52 | element.setAttribute('content', siteDescription) 53 | } else { 54 | const pageDescription = pageMetadata[page]?.description ?? removeNotionAds(content) 55 | 56 | element.setAttribute('content', pageDescription) 57 | } 58 | } 59 | 60 | if (property === 'og:url' || name === 'twitter:url') { 61 | if (this.isRootPage) { 62 | element.setAttribute('content', `https://${domain}/`) 63 | } else if (pageToSlug[page]) { 64 | element.setAttribute('content', `https://${domain}/${pageToSlug[page]}`) 65 | } else { 66 | element.setAttribute('content', `https://${domain}/${page}`) 67 | } 68 | } 69 | 70 | if (name === 'twitter:site') { 71 | if (twitterHandle) { 72 | element.setAttribute('content', `${twitterHandle}`) 73 | } else { 74 | element.remove() 75 | } 76 | } 77 | 78 | if (property === 'og:image' || name === 'twitter:image') { 79 | if (this.isRootPage && siteImage) { 80 | // console.log(`----- Image for url '${this.url.pathname}: ${siteImage}'`) 81 | element.setAttribute('content', siteImage) 82 | } else { 83 | const pageImage = pageMetadata[page]?.image ?? content 84 | // console.log(`----- Image for url '${this.url.pathname}: ${pageImage}'`) 85 | 86 | element.setAttribute('content', pageImage) 87 | } 88 | } 89 | 90 | if (name === 'apple-itunes-app') { 91 | element.remove() 92 | } 93 | } 94 | } 95 | 96 | function removeNotionAds(text: string) { 97 | return text 98 | .replace(' | Built with Notion', '') 99 | .replace(' | Notion', '') 100 | .replace('Built with Notion, the all-in-one connected workspace with publishing capabilities.', '') 101 | } 102 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export interface Env { 2 | slugs: KVNamespace 3 | } 4 | 5 | export type NoteHostSiteConfig = Omit 6 | 7 | export interface NoteHostSiteConfigFull { 8 | // Site domain, example.com 9 | domain: string 10 | 11 | // Mapping from slug to page ID 12 | slugToPage: Record 13 | notionSlugToPage?: NoteHostNotionSlugConfig 14 | pageMetadata?: Record 15 | 16 | // SEO metadata 17 | // title, og:site_name, article:author 18 | siteName: string 19 | // description, og:description, twitter:description 20 | siteDescription: string 21 | // twitter:site, twitter:creator 22 | twitterHandle?: string 23 | // og:image, twitter:image 24 | siteImage?: string 25 | 26 | // URL to custom favicon.ico 27 | siteIcon?: string 28 | 29 | // Additional safety: avoid serving extraneous Notion content from your website 30 | // Use the value from your Notion settings => Workspace => Settings => Domain 31 | notionDomain?: string 32 | 33 | // 404 Notion page to display to visitors, the default slug is '404' 34 | fof?: { 35 | page: string | undefined 36 | slug: string | undefined 37 | } 38 | 39 | subDomains?: Record 40 | 41 | // Google Font name, you can choose from https://fonts.google.com 42 | googleFont?: string 43 | 44 | // Custom CSS/JS to be injected in and 45 | customHeadCSS?: string 46 | customHeadJS?: string 47 | customBodyJS?: string 48 | 49 | // Calculated fields 50 | pageToSlug: Record 51 | slugs: Array 52 | pages: Array 53 | } 54 | 55 | export interface NoteHostSiteConfigSubDomainRedirect { 56 | redirect: string 57 | } 58 | 59 | export interface NoteHostNotionSlugConfig { 60 | // Notion database with mapping from slug to page ID 61 | // Columns: 62 | // | ------------ | ------------- | 63 | // | slug | page link | 64 | // | ------------ | ------------- | 65 | // ↑ ↑ 66 | // text Link to Notion page 67 | // 68 | databaseId: string 69 | 70 | // TODO: sync this DB to firebase via notesync 71 | // and save values to slugs KV on webhook from notesync 72 | } 73 | 74 | // Page SEO metadata 75 | // Overrides site-level metadata 76 | export interface NoteHostSiteConfigPageMetadata { 77 | // , og:title and twitter:title 78 | title?: string 79 | // description, og:description and twitter:description 80 | description?: string 81 | // og:image and twitter:image 82 | image?: string 83 | // article:author 84 | author?: string 85 | } -------------------------------------------------------------------------------- /templates/default/.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | tab_width = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.yml] 13 | indent_style = space 14 | -------------------------------------------------------------------------------- /templates/default/_gitignore: -------------------------------------------------------------------------------- 1 | # common 2 | .DS_Store 3 | /node_modules 4 | *-lock.* 5 | *.lock 6 | *.log 7 | 8 | /dist 9 | 10 | # wrangler project 11 | 12 | .dev.vars 13 | .wrangler/ 14 | .vscode 15 | .git 16 | -------------------------------------------------------------------------------- /templates/default/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "<%= packageJsonName %>", 3 | "version": "1.0.0", 4 | "main": "src/index.ts", 5 | "private": true, 6 | "scripts": { 7 | "prebuild": "node ./src/_build-page-script-js-string.js", 8 | "start": "npm run prebuild && npx --yes wrangler@latest dev", 9 | "deploy": "npm run prebuild && npx --yes wrangler@latest deploy", 10 | "logs": "npx --yes wrangler@latest tail" 11 | }, 12 | "dependencies": { 13 | "notehost": "^<%= notehostVersion %>" 14 | }, 15 | "devDependencies": { 16 | "@cloudflare/workers-types": "^4.20231218.0" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /templates/default/src/_build-page-script-js-string.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | 4 | const outFilePath = path.join(__dirname, '_page-script-js-string.ts') 5 | const pageScriptPath = path.join(__dirname, 'page-script.js') 6 | 7 | try { 8 | const pageScriptContent = fs.readFileSync(pageScriptPath, 'utf8') 9 | const escapedContent = pageScriptContent.replace(/\\/g, '\\') 10 | const finalContent = `export const PAGE_SCRIPT_JS_STRING = \`<script>\n${escapedContent}</script>\`` 11 | 12 | fs.writeFileSync(outFilePath, finalContent) 13 | console.log('Page script was built successfully!') 14 | } catch (error) { 15 | console.error('Failed to build page script:', error) 16 | } 17 | -------------------------------------------------------------------------------- /templates/default/src/_page-script-js-string.ts: -------------------------------------------------------------------------------- 1 | export const PAGE_SCRIPT_JS_STRING = `<script> 2 | /* eslint-disable func-names */ 3 | /* eslint-disable no-return-assign */ 4 | /* eslint-disable no-param-reassign */ 5 | 6 | // This script is injected into the Notion page and runs on every page load. 7 | window.onload = function () { 8 | setInterval(() => { 9 | // Remove all Notion tooltips on images 10 | document 11 | .querySelectorAll('div[style*="position: absolute; top: 4px;"]') 12 | ?.forEach((el) => (el.style.display = 'none')) 13 | 14 | // Remove hidden properties dropdown 15 | const propertiesDropdown = document.querySelector('div[aria-label="Page properties"]')?.nextElementSibling 16 | 17 | if (propertiesDropdown) { 18 | propertiesDropdown.style.display = 'none' 19 | } 20 | }, 1000) 21 | } 22 | </script>` -------------------------------------------------------------------------------- /templates/default/src/index.ts: -------------------------------------------------------------------------------- 1 | import { initializeReverseProxy, reverseProxy } from 'notehost' 2 | import { SITE_CONFIG } from './site-config' 3 | 4 | initializeReverseProxy(SITE_CONFIG) 5 | 6 | export default { 7 | async fetch(request: Request): Promise<Response> { 8 | return await reverseProxy(request) 9 | }, 10 | } 11 | -------------------------------------------------------------------------------- /templates/default/src/page-script.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable func-names */ 2 | /* eslint-disable no-return-assign */ 3 | /* eslint-disable no-param-reassign */ 4 | 5 | // This script is injected into the Notion page and runs on every page load. 6 | window.onload = function () { 7 | setInterval(() => { 8 | // Remove all Notion tooltips on images 9 | document 10 | .querySelectorAll('div[style*="position: absolute; top: 4px;"]') 11 | ?.forEach((el) => (el.style.display = 'none')) 12 | 13 | // Remove hidden properties dropdown 14 | const propertiesDropdown = document.querySelector('div[aria-label="Page properties"]')?.nextElementSibling 15 | 16 | if (propertiesDropdown) { 17 | propertiesDropdown.style.display = 'none' 18 | } 19 | }, 1000) 20 | } 21 | -------------------------------------------------------------------------------- /templates/default/src/site-config.ts: -------------------------------------------------------------------------------- 1 | import { NoteHostSiteConfig, googleTag } from 'notehost' 2 | import { PAGE_SCRIPT_JS_STRING } from './_page-script-js-string' 3 | 4 | // Set this to your Google Tag ID from Google Analytics 5 | const GOOGLE_TAG_ID = '' 6 | 7 | export const SITE_CONFIG: NoteHostSiteConfig = { 8 | domain: '<%= domainName %>', 9 | 10 | // Metatags, optional 11 | // For main page link preview 12 | siteName: '<%= siteName %>', 13 | siteDescription: '<%= siteDescription %>', 14 | siteImage: '<%= siteImage %>', 15 | 16 | // Twitter handle, optional 17 | // twitterHandle: '', 18 | 19 | // URL to custom favicon.ico 20 | // siteIcon: '', 21 | 22 | // Additional safety: avoid serving extraneous Notion content from your website 23 | // Use the value from your Notion settings => Workspace => Settings => Domain 24 | // notionDomain: '', 25 | 26 | // Map slugs (short page names) to Notion page IDs 27 | // Empty slug is your main page 28 | slugToPage: { 29 | '': '<%= mainPageId %>', 30 | contact: 'NOTION_PAGE_ID', 31 | about: 'NOTION_PAGE_ID', 32 | // Hint: you can use '/' in slug name to create subpages 33 | 'about/people': 'NOTION_PAGE_ID', 34 | }, 35 | 36 | // Rewrite meta tags for specific pages 37 | // Use the Notion page ID as the key 38 | // pageMetadata: { 39 | // 'NOTION_PAGE_ID': { 40 | // title: 'My Custom Page Title', 41 | // description: 'My custom page description', 42 | // image: 'https://imagehosting.com/images/page_preview.jpg', 43 | // author: 'My Name', 44 | // }, 45 | // }, 46 | 47 | // Subdomain redirects are optional 48 | // But it is recommended to have one for www 49 | subDomains: { 50 | www: { 51 | redirect: 'https://<%= domainName %>', 52 | }, 53 | }, 54 | 55 | // The 404 (not found) page is optional 56 | // If you don't have one, the default 404 page will be used 57 | // fof: { 58 | // page: "NOTION_PAGE_ID", 59 | // slug: "404", // default 60 | // }, 61 | 62 | // Google Font name, you can choose from https://fonts.google.com 63 | googleFont: 'Roboto', 64 | 65 | // Custom JS for head and body of a Notion page 66 | customHeadCSS: ``, 67 | customHeadJS: googleTag(GOOGLE_TAG_ID), 68 | customBodyJS: PAGE_SCRIPT_JS_STRING, 69 | } 70 | -------------------------------------------------------------------------------- /templates/default/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "moduleResolution": "node", 4 | "target": "esnext", 5 | "module": "esnext", 6 | "lib": [ 7 | "esnext" 8 | ], 9 | "types": [ 10 | "@cloudflare/workers-types" 11 | ] 12 | }, 13 | "include": [ 14 | "src/**/*.ts", 15 | "src/page-script.js", 16 | ], 17 | } 18 | -------------------------------------------------------------------------------- /templates/default/wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "<%= wranglerWorkerName %>" 2 | main = "src/index.ts" 3 | compatibility_date = "2024-01-08" 4 | workers_dev = true 5 | 6 | routes = [ 7 | { pattern = "<%= domainName %>", custom_domain = true }, 8 | { pattern = "www.<%= domainName %>", custom_domain = true } 9 | ] 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig to read more about this file */ 4 | /* Projects */ 5 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ 6 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 7 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 8 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 9 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 10 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 11 | /* Language and Environment */ 12 | "target": "ESNext", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 13 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 14 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 15 | // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ 16 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 17 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 18 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 19 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 20 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 21 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 22 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 23 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 24 | /* Modules */ 25 | "module": "commonjs", /* Specify what module code is generated. */ 26 | // "rootDir": "./", /* Specify the root folder within your source files. */ 27 | // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */ 28 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 29 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 30 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 31 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ 32 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 33 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 34 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 35 | // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ 36 | // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ 37 | // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ 38 | // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ 39 | "resolveJsonModule": true, /* Enable importing .json files. */ 40 | // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ 41 | // "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */ 42 | /* JavaScript Support */ 43 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ 44 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 45 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 46 | /* Emit */ 47 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 48 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 49 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 50 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 51 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 52 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ 53 | // "outDir": "./", /* Specify an output folder for all emitted files. */ 54 | // "removeComments": true, /* Disable emitting comments. */ 55 | // "noEmit": true, /* Disable emitting files from a compilation. */ 56 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 57 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ 58 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 59 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 60 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 61 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 62 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 63 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 64 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 65 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 66 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 67 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 68 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 69 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 70 | /* Interop Constraints */ 71 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 72 | // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ 73 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 74 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ 75 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 76 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 77 | /* Type Checking */ 78 | // "strict": true, /* Enable all strict type-checking options. */ 79 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ 80 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 81 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 82 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 83 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 84 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 85 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 86 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 87 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ 88 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ 89 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 90 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 91 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 92 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 93 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 94 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 95 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 96 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 97 | /* Completeness */ 98 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 99 | "skipLibCheck": true, /* Skip type checking all .d.ts files. */ 100 | "noUncheckedIndexedAccess": true, 101 | "noEmit": true, 102 | "types": [ 103 | "@cloudflare/workers-types", 104 | "@types/node" 105 | ], 106 | }, 107 | "include": [ 108 | "**/*.ts" 109 | ], 110 | "exclude": [ 111 | "node_modules", 112 | "dist", 113 | "templates", 114 | ], 115 | } 116 | --------------------------------------------------------------------------------