├── .eslintignore ├── .eslintrc.cjs ├── .github └── workflows │ └── build.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc ├── 2023-04-13-20-09-36.png ├── README.md ├── package.json ├── scripts └── sitemap.js ├── src ├── app.d.ts ├── app.html ├── hooks.server.ts ├── lib │ ├── config.ts │ ├── get-canonical-page-id.ts │ ├── get-config-value.ts │ ├── get-site-map.ts │ ├── icons │ │ ├── check.svelte │ │ ├── check.svg │ │ ├── chevron-down-icon.svelte │ │ ├── clear-icon.svelte │ │ ├── collection-view-board.svelte │ │ ├── collection-view-board.svg │ │ ├── collection-view-calendar.svelte │ │ ├── collection-view-calendar.svg │ │ ├── collection-view-gallery.svelte │ │ ├── collection-view-gallery.svg │ │ ├── collection-view-icon.svelte │ │ ├── collection-view-list.svelte │ │ ├── collection-view-list.svg │ │ ├── collection-view-table.svelte │ │ ├── collection-view-table.svg │ │ ├── copy.svelte │ │ ├── copy.svg │ │ ├── default-page-icon.svelte │ │ ├── empty-icon.svelte │ │ ├── file-icon.svelte │ │ ├── link-icon.svelte │ │ ├── loading-icon.svelte │ │ ├── property-icon.svelte │ │ ├── search-icon.tsx │ │ ├── type-checkbox.svelte │ │ ├── type-checkbox.svg │ │ ├── type-date.svelte │ │ ├── type-date.svg │ │ ├── type-email.svelte │ │ ├── type-email.svg │ │ ├── type-file.svelte │ │ ├── type-file.svg │ │ ├── type-formula.svelte │ │ ├── type-formula.svg │ │ ├── type-github.svelte │ │ ├── type-multi-select.svelte │ │ ├── type-multi-select.svg │ │ ├── type-number.svelte │ │ ├── type-number.svg │ │ ├── type-person-2.svelte │ │ ├── type-person-2.svg │ │ ├── type-person.svelte │ │ ├── type-person.svg │ │ ├── type-phone-number.svelte │ │ ├── type-phone-number.svg │ │ ├── type-relation.svelte │ │ ├── type-relation.svg │ │ ├── type-select.svelte │ │ ├── type-select.svg │ │ ├── type-text.svelte │ │ ├── type-text.svg │ │ ├── type-timestamp.svelte │ │ ├── type-timestamp.svg │ │ ├── type-title.svelte │ │ ├── type-title.svg │ │ ├── type-url.svelte │ │ └── type-url.svg │ ├── images │ │ ├── default-page-icon.svg │ │ ├── github.svg │ │ ├── svelte-logo.svg │ │ ├── svelte-welcome.png │ │ └── svelte-welcome.webp │ ├── map-image-url.ts │ ├── notion-api.ts │ ├── notion.ts │ ├── site-config.ts │ ├── style-object.ts │ └── types.ts ├── routes │ ├── +layout.svelte │ ├── +layout.ts │ ├── +page.server.ts │ ├── +page.svelte │ ├── +page.ts │ ├── Header.svelte │ ├── [pageId] │ │ ├── +page.server.ts │ │ ├── +page.svelte │ │ └── +page.ts │ ├── components │ │ ├── Asset.svelte │ │ ├── AssetWrapper.svelte │ │ ├── Block.svelte │ │ ├── BlockItem │ │ │ ├── Audio.svelte │ │ │ ├── Bookmark.svelte │ │ │ ├── Callout.svelte │ │ │ ├── Code.svelte │ │ │ ├── Column.svelte │ │ │ ├── ColumnList.svelte │ │ │ ├── Divider.svelte │ │ │ ├── EOI.svelte │ │ │ ├── File.svelte │ │ │ ├── Header.svelte │ │ │ ├── List.svelte │ │ │ ├── Quote.svelte │ │ │ ├── SyncPointerBlock.svelte │ │ │ ├── Text.svelte │ │ │ ├── ViewPage.svelte │ │ │ └── Waiting.svelte │ │ ├── BlockRender.svelte │ │ ├── Checkbox.svelte │ │ ├── Collection │ │ │ ├── Collection.svelte │ │ │ ├── CollectionCard.svelte │ │ │ ├── CollectionColumnTitle.svelte │ │ │ ├── CollectionProperty.svelte │ │ │ ├── CollectionPropertyCheckbox.svelte │ │ │ ├── CollectionPropertyFile.svelte │ │ │ ├── CollectionPropertyNumber.svelte │ │ │ ├── CollectionPropertyTime.svelte │ │ │ ├── CollectionPropteryFormula.svelte │ │ │ ├── CollectionRow.svelte │ │ │ ├── CollectionViewGallery.svelte │ │ │ ├── CollectionViewTabs.svelte │ │ │ ├── ColletionPropertySelect.svelte │ │ │ └── eval-formula.ts │ │ ├── Equation.svelte │ │ ├── GracefulImage.svelte │ │ ├── Header.svelte │ │ ├── LiteYoutubeEmbed.svelte │ │ ├── PageIcon.svelte │ │ ├── PageTitle.svelte │ │ ├── Pdf.svelte │ │ ├── RecursiveBlock.svelte │ │ ├── Render.svelte │ │ ├── Text.svelte │ │ ├── TextExternalLink.svelte │ │ ├── TextLink.svelte │ │ ├── TextPage.svelte │ │ └── TextPlain.svelte │ ├── notion.css │ ├── prism-theme.css │ └── store.ts └── site.config.ts ├── static ├── .nojekyll ├── 404.html ├── favicon.png └── robots.txt ├── svelte.config.js ├── tsconfig.json └── vite.config.ts /.eslintignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | 10 | # Ignore files for PNPM, NPM and YARN 11 | pnpm-lock.yaml 12 | package-lock.json 13 | yarn.lock 14 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'], 5 | plugins: ['svelte3', '@typescript-eslint'], 6 | ignorePatterns: ['*.cjs'], 7 | overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }], 8 | settings: { 9 | 'svelte3/typescript': () => require('typescript') 10 | }, 11 | parserOptions: { 12 | sourceType: 'module', 13 | ecmaVersion: 2020 14 | }, 15 | env: { 16 | browser: true, 17 | es2017: true, 18 | node: true 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Github Pages 2 | 3 | # Controls when the workflow will run 4 | on: 5 | # Allows you to run this workflow manually from the Actions tab 6 | workflow_dispatch: 7 | 8 | 9 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 10 | jobs: 11 | # This workflow contains a single job called "build" 12 | build: 13 | # The type of runner that the job will run on 14 | runs-on: ubuntu-latest 15 | 16 | # Steps represent a sequence of tasks that will be executed as part of the job 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v3 20 | - name: Install Dependencies 21 | run: yarn 22 | - name: Build 23 | run: yarn build 24 | - name: Deploy 25 | uses: JamesIves/github-pages-deploy-action@v4 26 | with: 27 | token: ${{ secrets.ACCESS_TOKEN }} 28 | repository-name: tiodot/svelte-notion 29 | branch: doc 30 | folder: build 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | .vercel 10 | .output 11 | vite.config.js.timestamp-* 12 | vite.config.ts.timestamp-* 13 | yarn.lock 14 | .idea -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | 10 | # Ignore files for PNPM, NPM and YARN 11 | pnpm-lock.yaml 12 | package-lock.json 13 | yarn.lock 14 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "singleQuote": true, 4 | "trailingComma": "none", 5 | "printWidth": 100, 6 | "plugins": ["prettier-plugin-svelte"], 7 | "pluginSearchDirs": ["."], 8 | "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] 9 | } 10 | -------------------------------------------------------------------------------- /2023-04-13-20-09-36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tiodot/svelte-notion/c17d3c2ed6017c6143a46eb802dbd007f890df56/2023-04-13-20-09-36.png -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Svelte Notion Kit 2 | 3 | This repo is using svelte to generate static blog site. Inspired by [react-notion-x](https://github.com/NotionX/react-notion-x) 4 | 5 | It uses Notion as a CMS, can be deployed to GitHub pages or Vercel; 6 | 7 | ## Features 8 | - Setup only takes a few minutes(single config file) 9 | - Build using Svelte without CSR that means only html files and css files 10 | - Excellent page speeds, no hydrate, no Javascript. 11 | - Responsive for different devices 12 | 13 | ## Demos 14 | https://xchb.work/svelte-notion/ 15 | 16 | ![](2023-04-13-20-09-36.png) 17 | ## Setup 18 | All config is defined in [site.config.ts](https://github.com/tiodot/svelte-notion/blob/main/src/site.config.ts). 19 | 20 | 1. Fork / clone this repo 21 | 2. Change a few values in [site.config.ts](https://github.com/tiodot/svelte-notion/blob/main/src/site.config.ts) 22 | 3. npm install 23 | 4. npm run dev to test locally 24 | 5. npm run deploy to deploy to vercel 💪 25 | 26 | I tried to make configuration as easy as possible — All you really need to do to get started is edit **rootNotionPageId**. 27 | 28 | We recommend duplicating the [default page](https://tiodot.notion.site/5e19b09eec9e43c5b4c23031d41fea81) as a starting point, but you can use any public notion page you want. 29 | 30 | Make sure your root Notion page is public and then copy the link to your clipboard. Extract the last part of the URL that looks like 7875426197cf461698809def95960ebf, which is your page's Notion ID. 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "svelte-notion", 3 | "version": "0.0.1", 4 | "scripts": { 5 | "dev": "vite dev", 6 | "build": "vite build", 7 | "preview": "vite preview", 8 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 9 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 10 | "lint": "prettier --plugin-search-dir . --check . && eslint .", 11 | "format": "prettier --plugin-search-dir . --write .", 12 | "sitemap": "HOST=https://example.com node scripts/sitemap.js" 13 | }, 14 | "devDependencies": { 15 | "@fontsource/fira-mono": "^4.5.10", 16 | "@neoconfetti/svelte": "^1.0.0", 17 | "@sveltejs/adapter-auto": "^2.0.0", 18 | "@sveltejs/adapter-static": "^2.0.1", 19 | "@sveltejs/kit": "^1.5.0", 20 | "@types/cookie": "^0.5.1", 21 | "@typescript-eslint/eslint-plugin": "^5.45.0", 22 | "@typescript-eslint/parser": "^5.45.0", 23 | "classnames": "^2.3.2", 24 | "date-fns": "^2.29.3", 25 | "eslint": "^8.28.0", 26 | "eslint-config-prettier": "^8.5.0", 27 | "eslint-plugin-svelte3": "^4.0.0", 28 | "format-number": "^3.0.0", 29 | "html-minifier": "^4.0.0", 30 | "notion-client": "^6.15.8", 31 | "notion-types": "^6.15.6", 32 | "notion-utils": "^6.15.8", 33 | "p-memoize": "^7.1.1", 34 | "prettier": "^2.8.0", 35 | "prettier-plugin-svelte": "^2.8.1", 36 | "prismjs": "^1.29.0", 37 | "querystring": "^0.2.1", 38 | "svelte": "^3.54.0", 39 | "svelte-check": "^3.0.1", 40 | "svelte-katex": "^0.1.2", 41 | "svelte-pdf": "^1.0.17", 42 | "tslib": "^2.4.1", 43 | "typescript": "^4.9.3", 44 | "vite": "^4.0.0" 45 | }, 46 | "type": "module" 47 | } 48 | -------------------------------------------------------------------------------- /scripts/sitemap.js: -------------------------------------------------------------------------------- 1 | /** 2 | * build sitemap from output 3 | */ 4 | import fs from 'fs'; 5 | import path from 'path'; 6 | import * as devalue from 'devalue' 7 | import { fileURLToPath } from 'url'; 8 | import {idToUuid} from 'notion-utils'; 9 | 10 | const host = process.env.HOST; 11 | 12 | 13 | const __filename = fileURLToPath(import.meta.url); 14 | const __dirname = path.dirname(__filename); 15 | 16 | const data = fs.readFileSync(path.resolve(__dirname, '../build/__data.json'), {encoding: 'utf-8'}); 17 | const blockMap = devalue.unflatten(JSON.parse(data).nodes[1].data)?.recordMap?.block ?? {}; 18 | 19 | 20 | function createSitemapXml(urls) { 21 | let urlsXml = '' 22 | urls.forEach(u => { 23 | urlsXml += ` 24 | ${u.loc} 25 | ${u.lastmod} 26 | ${u.changefreq} 27 | 0.7 28 | 29 | ` 30 | }) 31 | 32 | return ` 38 | ${urlsXml} 39 | ` 40 | } 41 | 42 | 43 | fs.readdir(path.resolve(__dirname, '../build'), {withFileTypes: true}, (err, files) => { 44 | // console.log(files) 45 | const filesNames = files.filter(file => file.isDirectory() && file.name !== '_app').map(file => file.name) 46 | // console.log(filesNames) 47 | const urls = [{ 48 | loc: host, 49 | lastmod: new Date().toISOString().split('T')[0], 50 | changefreq: 'daily' 51 | }] 52 | 53 | filesNames.forEach(name => { 54 | const matched = name.match(/-(\w{32})$/) 55 | if (matched && matched[1]) { 56 | const id = idToUuid(matched[1]) 57 | const block = blockMap[id] 58 | // console.log(block, '<----') 59 | urls.push({ 60 | loc: `${host}/${name}`, 61 | lastmod: new Date(block.value.last_edited_time || block.value.created_time).toISOString().split('T')[0], 62 | changefreq: 'daily' 63 | }) 64 | } 65 | }) 66 | // console.log(urls) 67 | const content = createSitemapXml(urls) 68 | fs.writeFile(path.resolve(__dirname, '../build/sitemap.xml'), content, console.log) 69 | }) 70 | -------------------------------------------------------------------------------- /src/app.d.ts: -------------------------------------------------------------------------------- 1 | // See https://kit.svelte.dev/docs/types#app 2 | // for information about these interfaces 3 | declare global { 4 | namespace App { 5 | // interface Error {} 6 | // interface Locals {} 7 | // interface PageData {} 8 | // interface Platform {} 9 | } 10 | } 11 | 12 | export {}; 13 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | %sveltekit.head% 8 | 9 | 10 |
%sveltekit.body%
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/hooks.server.ts: -------------------------------------------------------------------------------- 1 | import type { Handle } from '@sveltejs/kit'; 2 | 3 | import { minify } from 'html-minifier'; 4 | import { building } from '$app/environment'; 5 | 6 | const minification_options = { 7 | collapseBooleanAttributes: true, 8 | collapseWhitespace: true, 9 | html5: true, 10 | ignoreCustomComments: [/^#/], 11 | minifyCSS: true, 12 | minifyJS: false, 13 | removeComments: true, // some hydration code needs comments, so leave them in 14 | }; 15 | 16 | export const handle = (async ({ event, resolve }) => { 17 | let page = ''; 18 | 19 | return resolve(event, { 20 | transformPageChunk: ({ html, done }) => { 21 | page += html; 22 | if (done) { 23 | return building ? minify(page, minification_options) : page; 24 | } 25 | } 26 | }); 27 | }) satisfies Handle; 28 | -------------------------------------------------------------------------------- /src/lib/config.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Site-wide app configuration. 3 | * 4 | * This file pulls from the root "site.config.ts" as well as environment variables 5 | * for optional depenencies. 6 | */ 7 | import { parsePageId } from 'notion-utils'; 8 | 9 | import { getEnv, getSiteConfig } from './get-config-value'; 10 | import type { NavigationLink } from './site-config'; 11 | import type { 12 | NavigationStyle, 13 | PageUrlOverridesInverseMap, 14 | PageUrlOverridesMap, 15 | Site 16 | } from './types'; 17 | 18 | export const rootNotionPageId: string = parsePageId(getSiteConfig('rootNotionPageId'), { 19 | uuid: false 20 | }); 21 | 22 | if (!rootNotionPageId) { 23 | throw new Error('Config error invalid "rootNotionPageId"'); 24 | } 25 | 26 | // if you want to restrict pages to a single notion workspace (optional) 27 | export const rootNotionSpaceId: string | null = parsePageId( 28 | getSiteConfig('rootNotionSpaceId', null), 29 | { uuid: true } 30 | ); 31 | 32 | export const pageUrlOverrides = cleanPageUrlMap(getSiteConfig('pageUrlOverrides', {}) || {}, { 33 | label: 'pageUrlOverrides' 34 | }); 35 | 36 | export const pageUrlAdditions = cleanPageUrlMap(getSiteConfig('pageUrlAdditions', {}) || {}, { 37 | label: 'pageUrlAdditions' 38 | }); 39 | 40 | export const inversePageUrlOverrides = invertPageUrlOverrides(pageUrlOverrides); 41 | 42 | export const environment = process.env.NODE_ENV || 'development'; 43 | export const isDev = environment === 'development'; 44 | 45 | // general site config 46 | export const name: string = getSiteConfig('name'); 47 | export const author: string = getSiteConfig('author'); 48 | export const domain: string = getSiteConfig('domain'); 49 | export const description: string = getSiteConfig('description', 'Notion Blog'); 50 | export const language: string = getSiteConfig('language', 'en'); 51 | 52 | // default notion values for site-wide consistency (optional; may be overridden on a per-page basis) 53 | export const defaultPageIcon: string | null = getSiteConfig('defaultPageIcon', null); 54 | export const defaultPageCover: string | null = getSiteConfig('defaultPageCover', null); 55 | export const defaultPageCoverPosition: number = getSiteConfig('defaultPageCoverPosition', 0.5); 56 | 57 | // Optional whether or not to include the Notion ID in page URLs or just use slugs 58 | export const includeNotionIdInUrls: boolean = getSiteConfig('includeNotionIdInUrls', !!isDev); 59 | 60 | export const navigationStyle: NavigationStyle = getSiteConfig('navigationStyle', 'default'); 61 | 62 | export const navigationLinks: Array | null = getSiteConfig( 63 | 'navigationLinks', 64 | null 65 | ); 66 | 67 | // Optional site search 68 | export const isSearchEnabled: boolean = getSiteConfig('isSearchEnabled', false); 69 | 70 | // ---------------------------------------------------------------------------- 71 | 72 | // Optional redis instance for persisting preview images 73 | export const isRedisEnabled: boolean = 74 | getSiteConfig('isRedisEnabled', false) || !!getEnv('REDIS_ENABLED', ''); 75 | 76 | // (if you want to enable redis, only REDIS_HOST and REDIS_PASSWORD are required) 77 | // we recommend that you store these in a local `.env` file 78 | export const redisHost: string | null = getEnv('REDIS_HOST', ''); 79 | export const redisPassword: string | null = getEnv('REDIS_PASSWORD', ''); 80 | export const redisUser: string = getEnv('REDIS_USER', 'default'); 81 | export const redisUrl = getEnv('REDIS_URL', `redis://${redisUser}:${redisPassword}@${redisHost}`); 82 | export const redisNamespace: string | null = getEnv('REDIS_NAMESPACE', 'preview-images'); 83 | 84 | // ---------------------------------------------------------------------------- 85 | 86 | export const isServer = typeof window === 'undefined'; 87 | 88 | export const port = getEnv('PORT', '3000'); 89 | export const host = isDev ? `http://localhost:${port}` : `https://${domain}`; 90 | export const apiHost = isDev ? host : `https://${process.env.VERCEL_URL || domain}`; 91 | 92 | export const apiBaseUrl = `/api`; 93 | 94 | export const api = { 95 | searchNotion: `${apiBaseUrl}/search-notion`, 96 | getNotionPageInfo: `${apiBaseUrl}/notion-page-info`, 97 | getSocialImage: `${apiBaseUrl}/social-image` 98 | }; 99 | 100 | // ---------------------------------------------------------------------------- 101 | 102 | export const site: Site = { 103 | domain, 104 | name, 105 | rootNotionPageId, 106 | rootNotionSpaceId, 107 | description 108 | }; 109 | 110 | function cleanPageUrlMap( 111 | pageUrlMap: PageUrlOverridesMap, 112 | { 113 | label 114 | }: { 115 | label: string; 116 | } 117 | ): PageUrlOverridesMap { 118 | return Object.keys(pageUrlMap).reduce((acc, uri) => { 119 | const pageId = pageUrlMap[uri]; 120 | const uuid = parsePageId(pageId, { uuid: false }); 121 | 122 | if (!uuid) { 123 | throw new Error(`Invalid ${label} page id "${pageId}"`); 124 | } 125 | 126 | if (!uri) { 127 | throw new Error(`Missing ${label} value for page "${pageId}"`); 128 | } 129 | 130 | if (!uri.startsWith('/')) { 131 | throw new Error( 132 | `Invalid ${label} value for page "${pageId}": value "${uri}" should be a relative URI that starts with "/"` 133 | ); 134 | } 135 | 136 | const path = uri.slice(1); 137 | 138 | return { 139 | ...acc, 140 | [path]: uuid 141 | }; 142 | }, {}); 143 | } 144 | 145 | function invertPageUrlOverrides(pageUrlOverrides: PageUrlOverridesMap): PageUrlOverridesInverseMap { 146 | return Object.keys(pageUrlOverrides).reduce((acc, uri) => { 147 | const pageId = pageUrlOverrides[uri]; 148 | 149 | return { 150 | ...acc, 151 | [pageId]: uri 152 | }; 153 | }, {}); 154 | } 155 | -------------------------------------------------------------------------------- /src/lib/get-canonical-page-id.ts: -------------------------------------------------------------------------------- 1 | import type { ExtendedRecordMap } from 'notion-types' 2 | import { 3 | getCanonicalPageId as getCanonicalPageIdImpl, 4 | parsePageId 5 | } from 'notion-utils' 6 | 7 | import { inversePageUrlOverrides } from './config' 8 | 9 | export function getCanonicalPageId( 10 | pageId: string, 11 | recordMap: ExtendedRecordMap, 12 | { uuid = true }: { uuid?: boolean } = {} 13 | ): string | null { 14 | const cleanPageId = parsePageId(pageId, { uuid: false }) 15 | if (!cleanPageId) { 16 | return null 17 | } 18 | 19 | const override = inversePageUrlOverrides[cleanPageId] 20 | if (override) { 21 | return override 22 | } else { 23 | return getCanonicalPageIdImpl(pageId, recordMap, { 24 | uuid 25 | }) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/lib/get-config-value.ts: -------------------------------------------------------------------------------- 1 | import rawSiteConfig from '../site.config'; 2 | import type { SiteConfig } from './site-config'; 3 | 4 | if (!rawSiteConfig) { 5 | throw new Error(`Config error: invalid site.config.ts`); 6 | } 7 | 8 | // allow environment variables to override site.config.ts 9 | let siteConfigOverrides: SiteConfig; 10 | 11 | try { 12 | if (process.env.NEXT_PUBLIC_SITE_CONFIG) { 13 | siteConfigOverrides = JSON.parse(process.env.NEXT_PUBLIC_SITE_CONFIG); 14 | } 15 | } catch (err) { 16 | console.error('Invalid config "NEXT_PUBLIC_SITE_CONFIG" failed to parse'); 17 | throw err; 18 | } 19 | 20 | const siteConfig: SiteConfig = { 21 | ...rawSiteConfig, 22 | ...(siteConfigOverrides ?? {}) 23 | }; 24 | 25 | export function getSiteConfig(key: string, defaultValue?: T): T { 26 | const value = siteConfig[key as keyof SiteConfig]; 27 | 28 | if (value !== undefined) { 29 | return value as T; 30 | } 31 | 32 | if (defaultValue !== undefined) { 33 | return defaultValue; 34 | } 35 | 36 | throw new Error(`Config error: missing required site config value "${key}"`); 37 | } 38 | 39 | export function getEnv(key: string, defaultValue?: string, env = process.env): string { 40 | const value = env[key]; 41 | 42 | if (value !== undefined) { 43 | return value; 44 | } 45 | 46 | if (defaultValue !== undefined) { 47 | return defaultValue; 48 | } 49 | 50 | throw new Error(`Config error: missing required env variable "${key}"`); 51 | } 52 | -------------------------------------------------------------------------------- /src/lib/get-site-map.ts: -------------------------------------------------------------------------------- 1 | import { getAllPagesInSpace, uuidToId } from 'notion-utils'; 2 | import pMemoize from 'p-memoize'; 3 | 4 | import * as config from './config'; 5 | import type * as types from './types'; 6 | import { includeNotionIdInUrls } from './config'; 7 | import { getCanonicalPageId } from './get-canonical-page-id'; 8 | import { notion } from './notion-api'; 9 | 10 | const uuid = !!includeNotionIdInUrls; 11 | 12 | export async function getSiteMap(): Promise { 13 | const partialSiteMap = await getAllPages(config.rootNotionPageId, config.rootNotionSpaceId); 14 | 15 | return { 16 | site: config.site, 17 | ...partialSiteMap 18 | } as types.SiteMap; 19 | } 20 | 21 | const getAllPages = pMemoize(getAllPagesImpl, { 22 | cacheKey: (...args) => JSON.stringify(args) 23 | }); 24 | 25 | async function getAllPagesImpl( 26 | rootNotionPageId: string, 27 | rootNotionSpaceId: string 28 | ): Promise> { 29 | const getPage = async (pageId: string, ...args) => { 30 | // console.log('\nnotion getPage', uuidToId(pageId)); 31 | return notion.getPage(pageId, ...args); 32 | }; 33 | 34 | const pageMap = await getAllPagesInSpace(rootNotionPageId, rootNotionSpaceId, getPage); 35 | 36 | const canonicalPageMap = Object.keys(pageMap).reduce((map, pageId: string) => { 37 | const recordMap = pageMap[pageId]; 38 | if (!recordMap) { 39 | throw new Error(`Error loading page "${pageId}"`); 40 | } 41 | 42 | const canonicalPageId = getCanonicalPageId(pageId, recordMap, { 43 | uuid 44 | }); 45 | 46 | if (map[canonicalPageId]) { 47 | // you can have multiple pages in different collections that have the same id 48 | // TODO: we may want to error if neither entry is a collection page 49 | console.warn('error duplicate canonical page id', { 50 | canonicalPageId, 51 | pageId, 52 | existingPageId: map[canonicalPageId] 53 | }); 54 | 55 | return map; 56 | } else { 57 | return { 58 | ...map, 59 | [canonicalPageId]: pageId 60 | }; 61 | } 62 | }, {}); 63 | 64 | return { 65 | pageMap, 66 | canonicalPageMap 67 | }; 68 | } 69 | -------------------------------------------------------------------------------- /src/lib/icons/check.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/lib/icons/check.svg: -------------------------------------------------------------------------------- 1 | 2 | 8 | 11 | -------------------------------------------------------------------------------- /src/lib/icons/chevron-down-icon.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/lib/icons/clear-icon.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/lib/icons/collection-view-board.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/lib/icons/collection-view-board.svg: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | -------------------------------------------------------------------------------- /src/lib/icons/collection-view-calendar.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/lib/icons/collection-view-calendar.svg: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | -------------------------------------------------------------------------------- /src/lib/icons/collection-view-gallery.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/lib/icons/collection-view-gallery.svg: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | -------------------------------------------------------------------------------- /src/lib/icons/collection-view-icon.svelte: -------------------------------------------------------------------------------- 1 | 22 | -------------------------------------------------------------------------------- /src/lib/icons/collection-view-list.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/lib/icons/collection-view-list.svg: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | -------------------------------------------------------------------------------- /src/lib/icons/collection-view-table.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/lib/icons/collection-view-table.svg: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | -------------------------------------------------------------------------------- /src/lib/icons/copy.svelte: -------------------------------------------------------------------------------- 1 | 2 | 8 | 12 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/lib/icons/copy.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/lib/icons/default-page-icon.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/lib/icons/empty-icon.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/lib/icons/file-icon.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/lib/icons/link-icon.svelte: -------------------------------------------------------------------------------- 1 | 2 | 8 | 12 | 13 | -------------------------------------------------------------------------------- /src/lib/icons/loading-icon.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 31 | 37 | 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /src/lib/icons/property-icon.svelte: -------------------------------------------------------------------------------- 1 | 46 | 47 | -------------------------------------------------------------------------------- /src/lib/icons/search-icon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | import { cs } from '../utils' 4 | 5 | export const SearchIcon = (props) => { 6 | const { className, ...rest } = props 7 | return ( 8 | 9 | 10 | 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /src/lib/icons/type-checkbox.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/lib/icons/type-checkbox.svg: -------------------------------------------------------------------------------- 1 | 2 | 8 | 11 | -------------------------------------------------------------------------------- /src/lib/icons/type-date.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/lib/icons/type-date.svg: -------------------------------------------------------------------------------- 1 | 2 | 8 | 11 | -------------------------------------------------------------------------------- /src/lib/icons/type-email.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/lib/icons/type-email.svg: -------------------------------------------------------------------------------- 1 | 2 | 8 | 11 | -------------------------------------------------------------------------------- /src/lib/icons/type-file.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/lib/icons/type-file.svg: -------------------------------------------------------------------------------- 1 | 2 | 8 | 11 | -------------------------------------------------------------------------------- /src/lib/icons/type-formula.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/lib/icons/type-formula.svg: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | -------------------------------------------------------------------------------- /src/lib/icons/type-github.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/lib/icons/type-multi-select.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/lib/icons/type-multi-select.svg: -------------------------------------------------------------------------------- 1 | 2 | 8 | 11 | -------------------------------------------------------------------------------- /src/lib/icons/type-number.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/lib/icons/type-number.svg: -------------------------------------------------------------------------------- 1 | 2 | 8 | 11 | -------------------------------------------------------------------------------- /src/lib/icons/type-person-2.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/lib/icons/type-person-2.svg: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | -------------------------------------------------------------------------------- /src/lib/icons/type-person.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/lib/icons/type-person.svg: -------------------------------------------------------------------------------- 1 | 2 | 8 | 11 | -------------------------------------------------------------------------------- /src/lib/icons/type-phone-number.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/lib/icons/type-phone-number.svg: -------------------------------------------------------------------------------- 1 | 2 | 8 | 11 | -------------------------------------------------------------------------------- /src/lib/icons/type-relation.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/lib/icons/type-relation.svg: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | -------------------------------------------------------------------------------- /src/lib/icons/type-select.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/lib/icons/type-select.svg: -------------------------------------------------------------------------------- 1 | 2 | 8 | 11 | -------------------------------------------------------------------------------- /src/lib/icons/type-text.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/lib/icons/type-text.svg: -------------------------------------------------------------------------------- 1 | 2 | 8 | 11 | -------------------------------------------------------------------------------- /src/lib/icons/type-timestamp.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/lib/icons/type-timestamp.svg: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | -------------------------------------------------------------------------------- /src/lib/icons/type-title.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/lib/icons/type-title.svg: -------------------------------------------------------------------------------- 1 | 2 | 8 | 11 | -------------------------------------------------------------------------------- /src/lib/icons/type-url.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/lib/icons/type-url.svg: -------------------------------------------------------------------------------- 1 | 2 | 8 | 11 | -------------------------------------------------------------------------------- /src/lib/images/default-page-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/lib/images/github.svg: -------------------------------------------------------------------------------- 1 | 2 | 9 | 16 | -------------------------------------------------------------------------------- /src/lib/images/svelte-logo.svg: -------------------------------------------------------------------------------- 1 | svelte-logo -------------------------------------------------------------------------------- /src/lib/images/svelte-welcome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tiodot/svelte-notion/c17d3c2ed6017c6143a46eb802dbd007f890df56/src/lib/images/svelte-welcome.png -------------------------------------------------------------------------------- /src/lib/images/svelte-welcome.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tiodot/svelte-notion/c17d3c2ed6017c6143a46eb802dbd007f890df56/src/lib/images/svelte-welcome.webp -------------------------------------------------------------------------------- /src/lib/map-image-url.ts: -------------------------------------------------------------------------------- 1 | import type { Block } from 'notion-types'; 2 | 3 | import { defaultPageCover, defaultPageIcon } from './config'; 4 | 5 | const defaultMapImageUrl = (url: string, block: Block) => { 6 | if (!url) { 7 | return null; 8 | } 9 | if (url.startsWith('data:')) { 10 | return url; 11 | } 12 | if (url.startsWith('https://images.unsplash.com')) { 13 | return url; 14 | } 15 | 16 | if (url.startsWith('/images')) { 17 | url = `https://www.notion.so${url}`; 18 | } 19 | url = `https://www.notion.so${ 20 | url.startsWith('/image') ? url : `/image/${encodeURIComponent(url)}` 21 | }`; 22 | 23 | const notionImageUrlV2 = new URL(url); 24 | let table = block.parent_table === 'space' ? 'block' : block.parent_table; 25 | if (table === 'collection' || table === 'team') { 26 | table = 'block'; 27 | } 28 | notionImageUrlV2.searchParams.set('table', table); 29 | notionImageUrlV2.searchParams.set('id', block.id); 30 | notionImageUrlV2.searchParams.set('cache', 'v2'); 31 | url = notionImageUrlV2.toString(); 32 | return url; 33 | }; 34 | 35 | export const mapImageUrl = (url: string, block: Block) => { 36 | if (url === defaultPageCover || url === defaultPageIcon) { 37 | return url; 38 | } 39 | 40 | return defaultMapImageUrl(url, block); 41 | }; 42 | -------------------------------------------------------------------------------- /src/lib/notion-api.ts: -------------------------------------------------------------------------------- 1 | import { NotionAPI } from 'notion-client' 2 | 3 | export const notion = new NotionAPI({ 4 | apiBaseUrl: process.env.NOTION_API_BASE_URL 5 | }) 6 | -------------------------------------------------------------------------------- /src/lib/notion.ts: -------------------------------------------------------------------------------- 1 | import { NotionAPI } from 'notion-client'; 2 | import type { ExtendedRecordMap, SearchParams, SearchResults } from 'notion-types'; 3 | import { mergeRecordMaps, parsePageId } from 'notion-utils'; 4 | import pMap from 'p-map'; 5 | import pMemoize from 'p-memoize'; 6 | import { notion } from './notion-api'; 7 | 8 | import { 9 | navigationLinks, 10 | navigationStyle, 11 | pageUrlAdditions, 12 | pageUrlOverrides, 13 | site 14 | } from './config'; 15 | import { getSiteMap } from './get-site-map'; 16 | import type { SiteMap } from './types'; 17 | 18 | let globalSiteMap: SiteMap; 19 | 20 | const getNavigationLinkPages = pMemoize(async (): Promise => { 21 | const navigationLinkPageIds = (navigationLinks || []) 22 | .map((link) => link && link.pageId) 23 | .filter(Boolean); 24 | 25 | if (navigationStyle !== 'default' && navigationLinkPageIds.length) { 26 | return pMap( 27 | navigationLinkPageIds, 28 | async (navigationLinkPageId) => 29 | notion.getPage(navigationLinkPageId || '', { 30 | chunkLimit: 1, 31 | fetchMissingBlocks: false, 32 | fetchCollections: false, 33 | signFileUrls: false 34 | }), 35 | { 36 | concurrency: 4 37 | } 38 | ); 39 | } 40 | 41 | return []; 42 | }); 43 | 44 | export async function getPage(pageId: string): Promise { 45 | let recordMap = await notion.getPage(pageId); 46 | 47 | if (navigationStyle !== 'default') { 48 | // ensure that any pages linked to in the custom navigation header have 49 | // their block info fully resolved in the page record map so we know 50 | // the page title, slug, etc. 51 | const navigationLinkRecordMaps = await getNavigationLinkPages(); 52 | 53 | if (navigationLinkRecordMaps?.length) { 54 | recordMap = navigationLinkRecordMaps.reduce( 55 | (map, navigationLinkRecordMap) => mergeRecordMaps(map, navigationLinkRecordMap), 56 | recordMap 57 | ); 58 | } 59 | } 60 | 61 | return recordMap; 62 | } 63 | 64 | export async function resolveNotionPage(domain?: string, rawPageId?: string) { 65 | let pageId; 66 | let recordMap: ExtendedRecordMap; 67 | if (rawPageId && rawPageId !== 'index') { 68 | pageId = parsePageId(pageId); 69 | 70 | if (!pageId) { 71 | const override = pageUrlOverrides[rawPageId] || pageUrlAdditions[rawPageId]; 72 | 73 | if (override) { 74 | pageId = parsePageId(override); 75 | } 76 | } 77 | if (pageId) { 78 | recordMap = await getPage(pageId); 79 | } else { 80 | if (!globalSiteMap) { 81 | globalSiteMap = await getSiteMap(); 82 | } 83 | pageId = globalSiteMap?.canonicalPageMap[rawPageId]; 84 | if (pageId) { 85 | recordMap = await getPage(pageId); 86 | }else { 87 | throw Error('not ---> found') 88 | } 89 | } 90 | } else { 91 | recordMap = await getPage(site.rootNotionPageId); 92 | } 93 | return recordMap; 94 | } 95 | -------------------------------------------------------------------------------- /src/lib/site-config.ts: -------------------------------------------------------------------------------- 1 | import type * as types from './types'; 2 | 3 | export interface SiteConfig { 4 | rootNotionPageId: string; 5 | rootNotionSpaceId?: string; 6 | 7 | name: string; 8 | domain: string; 9 | author: string; 10 | description?: string; 11 | 12 | defaultPageIcon?: string | null; 13 | defaultPageCover?: string | null; 14 | defaultPageCoverPosition?: number | null; 15 | 16 | isSearchEnabled?: boolean; 17 | 18 | includeNotionIdInUrls?: boolean; 19 | pageUrlOverrides?: types.PageUrlOverridesMap; 20 | pageUrlAdditions?: types.PageUrlOverridesMap; 21 | 22 | navigationStyle?: types.NavigationStyle; 23 | } 24 | 25 | export interface NavigationLink { 26 | title: string; 27 | pageId?: string; 28 | url?: string; 29 | } 30 | 31 | export const siteConfig = (config: SiteConfig): SiteConfig => { 32 | return config; 33 | }; 34 | -------------------------------------------------------------------------------- /src/lib/style-object.ts: -------------------------------------------------------------------------------- 1 | export const camelCaseToDash = (obj: Record) => { 2 | return Object.keys(obj).reduce((pre, cur) => { 3 | if (obj[cur] === undefined) { 4 | return pre; 5 | } 6 | const dashKey = cur.replaceAll(/[A-W]/g, ($0) => `-${$0.toLocaleLowerCase()}`); 7 | return pre + `;${dashKey}: ${obj[cur]}`; 8 | }, ''); 9 | }; 10 | -------------------------------------------------------------------------------- /src/lib/types.ts: -------------------------------------------------------------------------------- 1 | import type { ExtendedRecordMap, PageMap } from 'notion-types'; 2 | import type { ParsedUrlQuery } from 'querystring'; 3 | 4 | export * from 'notion-types'; 5 | 6 | export type NavigationStyle = 'default' | 'custom'; 7 | 8 | export interface PageError { 9 | message?: string; 10 | statusCode: number; 11 | } 12 | 13 | export interface PageProps { 14 | site?: Site; 15 | recordMap?: ExtendedRecordMap; 16 | pageId?: string; 17 | error?: PageError; 18 | } 19 | 20 | export interface Params extends ParsedUrlQuery { 21 | pageId: string; 22 | } 23 | 24 | export interface Site { 25 | name: string; 26 | domain: string; 27 | 28 | rootNotionPageId: string; 29 | rootNotionSpaceId: string; 30 | 31 | // settings 32 | html?: string; 33 | fontFamily?: string; 34 | darkMode?: boolean; 35 | previewImages?: boolean; 36 | 37 | // opengraph metadata 38 | description?: string; 39 | image?: string; 40 | } 41 | 42 | export interface SiteMap { 43 | site: Site; 44 | pageMap: PageMap; 45 | canonicalPageMap: CanonicalPageMap; 46 | } 47 | 48 | export interface CanonicalPageMap { 49 | [canonicalPageId: string]: string; 50 | } 51 | 52 | export interface PageUrlOverridesMap { 53 | // maps from a URL path to the notion page id the page should be resolved to 54 | // (this overrides the built-in URL path generation for these pages) 55 | [pagePath: string]: string; 56 | } 57 | 58 | export interface PageUrlOverridesInverseMap { 59 | // maps from a notion page id to the URL path the page should be resolved to 60 | // (this overrides the built-in URL path generation for these pages) 61 | [pageId: string]: string; 62 | } 63 | 64 | export interface NotionPageInfo { 65 | pageId: string; 66 | title: string; 67 | image: string; 68 | imageObjectPosition: string; 69 | author: string; 70 | authorImage: string; 71 | detail: string; 72 | } 73 | -------------------------------------------------------------------------------- /src/routes/+layout.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 |
10 | 11 |
12 | 13 | 20 | -------------------------------------------------------------------------------- /src/routes/+layout.ts: -------------------------------------------------------------------------------- 1 | export const prerender = true; 2 | export const csr = false; 3 | -------------------------------------------------------------------------------- /src/routes/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { site } from '$lib/config'; 2 | import { resolveNotionPage } from '$lib/notion'; 3 | import { error } from '@sveltejs/kit'; 4 | import { getBlockTitle } from 'notion-utils'; 5 | 6 | export async function load() { 7 | const recordMap = await resolveNotionPage(); 8 | // console.log('props:', recordMap); 9 | 10 | const keys = Object.keys(recordMap.block) || {}; 11 | const block = recordMap.block[keys[0]].value; 12 | 13 | if (block.type.startsWith('collection_view') && block.collection_id) { 14 | const collection = recordMap.collection[block.collection_id].value; 15 | block.format = { 16 | ...block.format, 17 | page_cover: collection.cover || undefined, 18 | page_icon: collection.icon 19 | }; 20 | } 21 | const title = getBlockTitle(block, recordMap); 22 | return { title, recordMap, site }; 23 | } 24 | -------------------------------------------------------------------------------- /src/routes/+page.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | {title} 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /src/routes/+page.ts: -------------------------------------------------------------------------------- 1 | // since there's no dynamic data here, we can prerender 2 | // it so that it gets served as a static asset in production 3 | 4 | export const prerender = true; 5 | -------------------------------------------------------------------------------- /src/routes/Header.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 |
8 |
9 | 10 | SvelteKit 11 | 12 |
13 | 14 | 31 | 32 |
33 | 34 | GitHub 35 | 36 |
37 |
38 | 39 | 128 | -------------------------------------------------------------------------------- /src/routes/[pageId]/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { domain, site } from '$lib/config'; 2 | import { resolveNotionPage } from '$lib/notion'; 3 | import { getBlockTitle } from 'notion-utils'; 4 | 5 | export async function load({ params }: {params: {pageId: string}}) { 6 | console.log('request pageId:', params) 7 | const recordMap = await resolveNotionPage(domain, params.pageId); 8 | // console.log('props:', recordMap); 9 | const keys = Object.keys(recordMap.block) || {}; 10 | const block = recordMap.block[keys[0]].value; 11 | const title = getBlockTitle(block, recordMap); 12 | return { title, recordMap, site }; 13 | } 14 | -------------------------------------------------------------------------------- /src/routes/[pageId]/+page.svelte: -------------------------------------------------------------------------------- 1 | 2 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | {title} 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/routes/[pageId]/+page.ts: -------------------------------------------------------------------------------- 1 | export const prerender = 'auto'; -------------------------------------------------------------------------------- /src/routes/components/Asset.svelte: -------------------------------------------------------------------------------- 1 | 343 | 344 | {#if !invalid} 345 |
346 | {#if component.type === 'element'} 347 | 348 | {:else} 349 | 350 | {/if} 351 | {#if block?.type === 'image'} 352 | 353 | {/if} 354 |
355 | {#if block.type !== 'image'} 356 | 357 | {/if} 358 | {/if} 359 | -------------------------------------------------------------------------------- /src/routes/components/AssetWrapper.svelte: -------------------------------------------------------------------------------- 1 | 49 | 50 | {#if isURL} 51 | {@const captionHostname = extractHostname(caption)} 52 | 59 |
60 | 61 | {#if block?.properties?.caption && !isURL} 62 |
63 | 64 |
65 | {/if} 66 |
67 |
68 |
69 | {:else} 70 |
71 | 72 | {#if block?.properties?.caption && !isURL} 73 |
74 | 75 |
76 | {/if} 77 |
78 |
79 | {/if} 80 | -------------------------------------------------------------------------------- /src/routes/components/Block.svelte: -------------------------------------------------------------------------------- 1 | 86 | 87 | 88 | 89 | 90 | -------------------------------------------------------------------------------- /src/routes/components/BlockItem/Audio.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 |
11 | 12 |
-------------------------------------------------------------------------------- /src/routes/components/BlockItem/Bookmark.svelte: -------------------------------------------------------------------------------- 1 | 19 | 20 | {#if block.properties} 21 | {@const link = block.properties.link} 22 | {#if link && link[0]?.[0]} 23 | {@const title = getTitle(link)} 24 | 64 | {/if} 65 | {/if} 66 | -------------------------------------------------------------------------------- /src/routes/components/BlockItem/Callout.svelte: -------------------------------------------------------------------------------- 1 | 16 | 17 |
18 | 19 | 20 |
21 |
22 | -------------------------------------------------------------------------------- /src/routes/components/BlockItem/Code.svelte: -------------------------------------------------------------------------------- 1 | 66 | 67 |
{@html htmlStr}
71 | {#if caption} 72 |
73 | 74 |
75 | {/if} 76 | -------------------------------------------------------------------------------- /src/routes/components/BlockItem/Column.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 |
17 |
18 | -------------------------------------------------------------------------------- /src/routes/components/BlockItem/ColumnList.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
6 | 7 |
8 | -------------------------------------------------------------------------------- /src/routes/components/BlockItem/Divider.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
-------------------------------------------------------------------------------- /src/routes/components/BlockItem/EOI.svelte: -------------------------------------------------------------------------------- 1 | 47 | 48 | {#if isValid} 49 | 57 | {#if data.externalImageType} 58 |
59 | 60 |
61 | {/if} 62 |
63 |
{data.title}
64 | 65 | {#if data.owner || data.lastUpdated} 66 |
67 | {#if data.owner} 68 | {data.owner} 69 | {/if} 70 | {#if data.owner && data.lastUpdated} 71 | 72 | {/if} 73 | {#if data.lastUpdated} 74 | Updated {data.lastUpdated} 75 | {/if} 76 |
77 | {/if} 78 |
79 |
80 | {/if} 81 | -------------------------------------------------------------------------------- /src/routes/components/BlockItem/File.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 29 | -------------------------------------------------------------------------------- /src/routes/components/BlockItem/Header.svelte: -------------------------------------------------------------------------------- 1 | 62 | {#if block.properties} 63 | {#if block.format?.toggleable} 64 |
65 | 66 | 67 | 68 |
69 | 70 | 71 | 72 |
73 |
74 |
75 | 76 |
77 | {:else } 78 | 79 | 80 |
81 | 82 | 83 | 84 | 85 | 86 |
87 |
88 | {/if} 89 | {/if} -------------------------------------------------------------------------------- /src/routes/components/BlockItem/List.svelte: -------------------------------------------------------------------------------- 1 | 59 | 60 | 61 | {#if block.content} 62 | {#if block.properties} 63 |
  • 64 | 65 |
  • 66 | {/if} 67 | 68 | 69 | 70 | {:else if block.properties} 71 |
  • 72 | 73 |
  • 74 | {/if} 75 |
    -------------------------------------------------------------------------------- /src/routes/components/BlockItem/Quote.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 | {#if block.properties} 9 | {@const blockColor = block.format?.block_color} 10 |
    11 | {/if} 12 | -------------------------------------------------------------------------------- /src/routes/components/BlockItem/SyncPointerBlock.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | 14 | {#if referenceBlockId} 15 | 16 | {/if} 17 | -------------------------------------------------------------------------------- /src/routes/components/BlockItem/Text.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 | {#if !block.properties && !block.content?.length} 10 |
     
    11 | {:else } 12 |
    14 | {#if block.properties?.title} 15 | 16 | {/if} 17 | 18 | 19 |
    20 | {/if} -------------------------------------------------------------------------------- /src/routes/components/BlockItem/ViewPage.svelte: -------------------------------------------------------------------------------- 1 | 69 | 70 | 71 | {#if level === 0} 72 | {@const properties = blockType === 'page' ? block.properties : getCollectionProperties()} 73 |
    74 |
    75 |
    76 |
    77 | 78 |
    79 | {#if hasPageCover} 80 | 81 |
    82 | {getTextContent(properties?.title)} 88 |
    89 |
    90 | {/if} 91 |
    92 | {#if page_icon} 93 | 94 | {/if} 95 | 96 |

    97 | 98 |

    99 | 100 | {#if blockType === 'collection_view_page' || (blockType === 'page' && block.parent_table === 'collection')} 101 | 102 | {/if} 103 | 104 | {#if blockType !== 'collection_view_page'} 105 |
    106 |
    107 | 108 |
    109 | 110 | {#if hasAside} 111 | 112 | {/if} 113 |
    114 | {/if} 115 | 116 |
    117 |
    118 |
    119 |
    120 | {:else} 121 | 126 | 127 | 128 | {/if} 129 | -------------------------------------------------------------------------------- /src/routes/components/BlockItem/Waiting.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 |
    empty {block.type}
    7 | -------------------------------------------------------------------------------- /src/routes/components/BlockRender.svelte: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/routes/components/Checkbox.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | {#if isChecked} 8 |
    9 | 10 | 11 | 12 |
    13 | {:else} 14 |
    15 | {/if} 16 | 17 | -------------------------------------------------------------------------------- /src/routes/components/Collection/Collection.svelte: -------------------------------------------------------------------------------- 1 | 37 | 38 | {#if !isInValid} 39 | {#if block.type === 'page'} 40 | 41 | {:else } 42 |
    43 |
    44 | {#if viewIds.length > 1 && showCollectionViewDropdown} 45 | {defaultCollectionViewId = id }} 49 | /> 50 | {/if} 51 |
    52 | {#if showTitle} 53 |
    54 |
    55 | 60 | {title} 61 |
    62 |
    63 | {/if} 64 |
    65 |
    66 | 71 |
    72 | {/if} 73 | {/if} 74 | -------------------------------------------------------------------------------- /src/routes/components/Collection/CollectionCard.svelte: -------------------------------------------------------------------------------- 1 | 128 | 129 | 133 | {#if coverContent || cover?.type !== 'none'} 134 |
    135 | {#if coverContent.type === 'image'} 136 | {coverContent.alt 142 | {:else if coverContent.type === 'empty'} 143 |
    144 | {:else if coverContent.type === 'property'} 145 | 151 | {/if} 152 |
    153 | {/if} 154 | 155 | 156 |
    157 |
    158 | 165 |
    166 | 167 | {#each collectionProperties as property (property.key)} 168 |
    169 | 177 |
    178 | {/each} 179 |
    180 |
    181 | -------------------------------------------------------------------------------- /src/routes/components/Collection/CollectionColumnTitle.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 |
    10 | 14 | 15 |
    {schema.name}
    16 |
    -------------------------------------------------------------------------------- /src/routes/components/Collection/CollectionProperty.svelte: -------------------------------------------------------------------------------- 1 | 74 | 75 | {#if isValid} 76 | 77 | {#if schema.type === 'title'} 78 | {#if block && linkToTitlePage} 79 | 84 | 85 | 86 | {:else} 87 | 88 | {/if} 89 | {/if} 90 | {#if componentsMap[schema.type]} 91 | 101 | {/if} 102 | 103 | {/if} 104 | -------------------------------------------------------------------------------- /src/routes/components/Collection/CollectionPropertyCheckbox.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 |
    13 | 14 | {schema.name} 15 |
    -------------------------------------------------------------------------------- /src/routes/components/Collection/CollectionPropertyFile.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | {#each files as file, index (index)} 15 | 21 | 22 | 23 | {/each} 24 | -------------------------------------------------------------------------------- /src/routes/components/Collection/CollectionPropertyNumber.svelte: -------------------------------------------------------------------------------- 1 | 58 | 59 | {#if output} 60 | 61 | {:else } 62 | 63 | {/if} -------------------------------------------------------------------------------- /src/routes/components/Collection/CollectionPropertyTime.svelte: -------------------------------------------------------------------------------- 1 | 29 | 30 | {value} -------------------------------------------------------------------------------- /src/routes/components/Collection/CollectionPropteryFormula.svelte: -------------------------------------------------------------------------------- 1 | 28 | 29 | {content} 30 | -------------------------------------------------------------------------------- /src/routes/components/Collection/CollectionRow.svelte: -------------------------------------------------------------------------------- 1 | 46 | 47 |
    48 |
    49 | {#each propertyIds as propertyId(propertyId)} 50 | {@const schema = schemas[propertyId]} 51 |
    52 | 53 | 54 |
    55 | 63 |
    64 |
    65 | {/each} 66 |
    67 |
    -------------------------------------------------------------------------------- /src/routes/components/Collection/CollectionViewGallery.svelte: -------------------------------------------------------------------------------- 1 | 27 | 28 | -------------------------------------------------------------------------------- /src/routes/components/Collection/CollectionViewTabs.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
    12 | {#each viewIds as viewId (viewId)} 13 | {@const collectionView = $recordMapStore.collection_view[viewId]?.value} 14 | 27 | {/each} 28 |
    29 | -------------------------------------------------------------------------------- /src/routes/components/Collection/ColletionPropertySelect.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 | {#each values as value, index (index)} 16 |
    17 | {value} 18 |
    19 | {/each} 20 | -------------------------------------------------------------------------------- /src/routes/components/Collection/eval-formula.ts: -------------------------------------------------------------------------------- 1 | import type * as types from 'notion-types' 2 | import add from 'date-fns/add/index.js' 3 | import format from 'date-fns/format/index.js' 4 | import getDate from 'date-fns/getDate/index.js' 5 | import getDay from 'date-fns/getDay/index.js' 6 | import getHours from 'date-fns/getHours/index.js' 7 | import getMinutes from 'date-fns/getMinutes/index.js' 8 | import getMonth from 'date-fns/getMonth/index.js' 9 | import getYear from 'date-fns/getYear/index.js' 10 | import intervalToDuration from 'date-fns/intervalToDuration/index.js' 11 | import sub from 'date-fns/sub/index.js' 12 | import { getDateValue, getTextContent } from 'notion-utils' 13 | 14 | export interface EvalFormulaContext { 15 | properties: types.PropertyMap 16 | schema: types.CollectionPropertySchemaMap 17 | 18 | endDate?: boolean 19 | } 20 | 21 | /** 22 | * Evaluates a Notion formula expression to a result value. 23 | * 24 | * All built-in functions and operators are supported. 25 | * 26 | * NOTE: this needs a lot more testing, especially around covering all the different 27 | * function types and coercing different property values correctly. 28 | * 29 | * It does work for many of our test cases, however. 30 | * 31 | * @param formula - Formula to evaluate. 32 | * @param context - Collection context containing property schema and values. 33 | */ 34 | export function evalFormula( 35 | formula: types.Formula, 36 | context: EvalFormulaContext 37 | ): types.FormulaResult { 38 | const { endDate, ...ctx } = context 39 | 40 | // TODO: coerce return type using `formula.return_type` 41 | switch (formula?.type) { 42 | case 'symbol': 43 | // TODO: this isn't documented anywhere but seen in the wild 44 | return formula.name === 'true' 45 | 46 | case 'constant': { 47 | const value = formula.value 48 | switch (formula.result_type) { 49 | case 'text': 50 | return value 51 | case 'number': 52 | return parseFloat(value) 53 | default: 54 | return value 55 | } 56 | } 57 | 58 | case 'property': { 59 | const value = ctx.properties[formula.id] 60 | const text = getTextContent(value) 61 | 62 | switch (formula.result_type) { 63 | case 'text': 64 | return text 65 | 66 | case 'number': 67 | return parseFloat(text) 68 | 69 | case 'boolean': 70 | // TODO: handle chceckbox properties 71 | if (typeof text === 'string') { 72 | return text === 'true' 73 | } else { 74 | return !!text 75 | } 76 | 77 | case 'date': { 78 | // console.log('date', text, value) 79 | 80 | const v = getDateValue(value) 81 | if (v) { 82 | if (endDate && v.end_date) { 83 | const date = new Date(v.end_date) 84 | return new Date( 85 | date.getUTCFullYear(), 86 | date.getUTCMonth(), 87 | date.getUTCDate() 88 | ) 89 | } else { 90 | const date = new Date(v.start_date) 91 | return new Date( 92 | date.getUTCFullYear(), 93 | date.getUTCMonth(), 94 | date.getUTCDate() 95 | ) 96 | } 97 | } else { 98 | return new Date(text) 99 | } 100 | } 101 | 102 | default: 103 | return text 104 | } 105 | } 106 | 107 | case 'operator': 108 | // All operators are exposed as functions, so we handle them the same 109 | 110 | // eslint-disable-next-line no-fallthrough 111 | case 'function': 112 | return evalFunctionFormula(formula, ctx) 113 | 114 | default: 115 | // console.log(formula) 116 | throw new Error( 117 | `invalid or unsupported formula "${(formula as any)?.type}` 118 | ) 119 | } 120 | } 121 | 122 | /** 123 | * Evaluates a Notion formula function or operator expression. 124 | * 125 | * Note that all operators are also exposed as functions, so we handle them the same. 126 | * 127 | * @private 128 | */ 129 | function evalFunctionFormula( 130 | formula: types.FunctionFormula | types.OperatorFormula, 131 | ctx: EvalFormulaContext 132 | ): types.FormulaResult { 133 | const args = formula?.args 134 | 135 | switch (formula.name) { 136 | // logic 137 | // ------------------------------------------------------------------------ 138 | 139 | case 'and': 140 | return evalFormula(args[0], ctx) && evalFormula(args[1], ctx) 141 | 142 | case 'empty': 143 | return !evalFormula(args[0], ctx) 144 | 145 | case 'equal': 146 | // eslint-disable-next-line eqeqeq 147 | return evalFormula(args[0], ctx) == evalFormula(args[1], ctx) 148 | 149 | case 'if': 150 | return evalFormula(args[0], ctx) 151 | ? evalFormula(args[1], ctx) 152 | : evalFormula(args[2], ctx) 153 | 154 | case 'larger': 155 | return evalFormula(args[0], ctx) > evalFormula(args[1], ctx) 156 | 157 | case 'largerEq': 158 | return evalFormula(args[0], ctx) >= evalFormula(args[1], ctx) 159 | 160 | case 'not': 161 | return !evalFormula(args[0], ctx) 162 | 163 | case 'or': 164 | return evalFormula(args[0], ctx) || evalFormula(args[1], ctx) 165 | 166 | case 'smaller': 167 | return evalFormula(args[0], ctx) < evalFormula(args[1], ctx) 168 | 169 | case 'smallerEq': 170 | return evalFormula(args[0], ctx) <= evalFormula(args[1], ctx) 171 | 172 | case 'unequal': 173 | // eslint-disable-next-line eqeqeq 174 | return evalFormula(args[0], ctx) != evalFormula(args[1], ctx) 175 | 176 | // numeric 177 | // ------------------------------------------------------------------------ 178 | 179 | case 'abs': 180 | return Math.abs(evalFormula(args[0], ctx) as number) 181 | 182 | case 'add': { 183 | const v0 = evalFormula(args[0], ctx) 184 | const v1 = evalFormula(args[1], ctx) 185 | 186 | if (typeof v0 === 'number') { 187 | return v0 + +v1 188 | } else if (typeof v0 === 'string') { 189 | return v0 + `${v1}` 190 | } else { 191 | // TODO 192 | return v0 193 | } 194 | } 195 | 196 | case 'cbrt': 197 | return Math.cbrt(evalFormula(args[0], ctx) as number) 198 | 199 | case 'ceil': 200 | return Math.ceil(evalFormula(args[0], ctx) as number) 201 | 202 | case 'divide': 203 | return ( 204 | (evalFormula(args[0], ctx) as number) / 205 | (evalFormula(args[1], ctx) as number) 206 | ) 207 | 208 | case 'exp': 209 | return Math.exp(evalFormula(args[0], ctx) as number) 210 | 211 | case 'floor': 212 | return Math.floor(evalFormula(args[0], ctx) as number) 213 | 214 | case 'ln': 215 | return Math.log(evalFormula(args[0], ctx) as number) 216 | 217 | case 'log10': 218 | return Math.log10(evalFormula(args[0], ctx) as number) 219 | 220 | case 'log2': 221 | return Math.log2(evalFormula(args[0], ctx) as number) 222 | 223 | case 'max': { 224 | const values = args.map((arg) => evalFormula(arg, ctx) as number) 225 | return values.reduce( 226 | (acc, value) => Math.max(acc, value), 227 | Number.NEGATIVE_INFINITY 228 | ) 229 | } 230 | 231 | case 'min': { 232 | const values = args.map((arg) => evalFormula(arg, ctx) as number) 233 | return values.reduce( 234 | (acc, value) => Math.min(acc, value), 235 | Number.POSITIVE_INFINITY 236 | ) 237 | } 238 | 239 | case 'mod': 240 | return ( 241 | (evalFormula(args[0], ctx) as number) % 242 | (evalFormula(args[1], ctx) as number) 243 | ) 244 | 245 | case 'multiply': 246 | return ( 247 | (evalFormula(args[0], ctx) as number) * 248 | (evalFormula(args[1], ctx) as number) 249 | ) 250 | 251 | case 'pow': 252 | return Math.pow( 253 | evalFormula(args[0], ctx) as number, 254 | evalFormula(args[1], ctx) as number 255 | ) 256 | 257 | case 'round': 258 | return Math.round(evalFormula(args[0], ctx) as number) 259 | 260 | case 'sign': 261 | return Math.sign(evalFormula(args[0], ctx) as number) 262 | 263 | case 'sqrt': 264 | return Math.sqrt(evalFormula(args[0], ctx) as number) 265 | 266 | case 'subtract': 267 | return ( 268 | (evalFormula(args[0], ctx) as number) - 269 | (evalFormula(args[1], ctx) as number) 270 | ) 271 | 272 | case 'toNumber': 273 | return parseFloat(evalFormula(args[0], ctx) as string) 274 | 275 | case 'unaryMinus': 276 | return (evalFormula(args[0], ctx) as number) * -1 277 | 278 | case 'unaryPlus': 279 | return parseFloat(evalFormula(args[0], ctx) as string) 280 | 281 | // text 282 | // ------------------------------------------------------------------------ 283 | 284 | case 'concat': { 285 | const values = args.map((arg) => evalFormula(arg, ctx) as number) 286 | return values.join('') 287 | } 288 | 289 | case 'contains': 290 | return (evalFormula(args[0], ctx) as string).includes( 291 | evalFormula(args[1], ctx) as string 292 | ) 293 | 294 | case 'format': { 295 | const value = evalFormula(args[0], ctx) 296 | 297 | switch (typeof value) { 298 | case 'string': 299 | return value 300 | 301 | case 'object': 302 | if (value instanceof Date) { 303 | return format(value as Date, 'MMM d, YYY') 304 | } else { 305 | // shouldn't ever get here 306 | return `${value}` 307 | } 308 | 309 | case 'number': 310 | default: 311 | return `${value}` 312 | } 313 | } 314 | 315 | case 'join': { 316 | const [delimiterArg, ...restArgs] = args 317 | const delimiter = evalFormula(delimiterArg, ctx) as string 318 | const values = restArgs.map((arg) => evalFormula(arg, ctx) as number) 319 | return values.join(delimiter) 320 | } 321 | 322 | case 'length': 323 | return (evalFormula(args[0], ctx) as string).length 324 | 325 | case 'replace': { 326 | const value = evalFormula(args[0], ctx) as string 327 | const regex = evalFormula(args[1], ctx) as string 328 | const replacement = evalFormula(args[2], ctx) as string 329 | return value.replace(new RegExp(regex), replacement) 330 | } 331 | 332 | case 'replaceAll': { 333 | const value = evalFormula(args[0], ctx) as string 334 | const regex = evalFormula(args[1], ctx) as string 335 | const replacement = evalFormula(args[2], ctx) as string 336 | return value.replace(new RegExp(regex, 'g'), replacement) 337 | } 338 | 339 | case 'slice': { 340 | const value = evalFormula(args[0], ctx) as string 341 | const beginIndex = evalFormula(args[1], ctx) as number 342 | const endIndex = args[2] 343 | ? (evalFormula(args[2], ctx) as number) 344 | : value.length 345 | return value.slice(beginIndex, endIndex) 346 | } 347 | 348 | case 'test': { 349 | const value = evalFormula(args[0], ctx) as string 350 | const regex = evalFormula(args[1], ctx) as string 351 | return new RegExp(regex).test(value) 352 | } 353 | 354 | // date & time 355 | // ------------------------------------------------------------------------ 356 | 357 | case 'date': 358 | return getDate(evalFormula(args[0], ctx) as Date) 359 | 360 | case 'dateAdd': { 361 | const date = evalFormula(args[0], ctx) as Date 362 | const number = evalFormula(args[1], ctx) as number 363 | const unit = evalFormula(args[1], ctx) as string 364 | return add(date, { [unit]: number }) 365 | } 366 | 367 | case 'dateBetween': { 368 | const date1 = evalFormula(args[0], ctx) as Date 369 | const date2 = evalFormula(args[1], ctx) as Date 370 | const unit = evalFormula(args[1], ctx) as string 371 | return ( 372 | intervalToDuration({ 373 | start: date2, 374 | end: date1 375 | }) as any 376 | )[unit] as number 377 | } 378 | 379 | case 'dateSubtract': { 380 | const date = evalFormula(args[0], ctx) as Date 381 | const number = evalFormula(args[1], ctx) as number 382 | const unit = evalFormula(args[1], ctx) as string 383 | return sub(date, { [unit]: number }) 384 | } 385 | 386 | case 'day': 387 | return getDay(evalFormula(args[0], ctx) as Date) 388 | 389 | case 'end': 390 | return evalFormula(args[0], { ...ctx, endDate: true }) as Date 391 | 392 | case 'formatDate': { 393 | const date = evalFormula(args[0], ctx) as Date 394 | const formatValue = (evalFormula(args[1], ctx) as string).replace( 395 | 'dddd', 396 | 'eeee' 397 | ) 398 | return format(date, formatValue) 399 | } 400 | 401 | case 'fromTimestamp': 402 | return new Date(evalFormula(args[0], ctx) as number) 403 | 404 | case 'hour': 405 | return getHours(evalFormula(args[0], ctx) as Date) 406 | 407 | case 'minute': 408 | return getMinutes(evalFormula(args[0], ctx) as Date) 409 | 410 | case 'month': 411 | return getMonth(evalFormula(args[0], ctx) as Date) 412 | 413 | case 'now': 414 | return new Date() 415 | 416 | case 'start': 417 | return evalFormula(args[0], { ...ctx, endDate: false }) as Date 418 | 419 | case 'timestamp': 420 | return (evalFormula(args[0], ctx) as Date).getTime() 421 | 422 | case 'year': 423 | return getYear(evalFormula(args[0], ctx) as Date) 424 | 425 | default: 426 | // console.log(formula) 427 | throw new Error( 428 | `invalid or unsupported function formula "${(formula as any)?.type}` 429 | ) 430 | } 431 | } 432 | -------------------------------------------------------------------------------- /src/routes/components/Equation.svelte: -------------------------------------------------------------------------------- 1 | 15 | {#if value} 16 | 17 | 18 | {value} 19 | 20 | 21 | {/if} 22 | -------------------------------------------------------------------------------- /src/routes/components/GracefulImage.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 | {#if userInfo} 16 | {userInfo.given_name} 17 | {/if} 18 | -------------------------------------------------------------------------------- /src/routes/components/Header.svelte: -------------------------------------------------------------------------------- 1 | 88 | 89 |
    90 |
    91 | 119 |
    120 |
    121 | -------------------------------------------------------------------------------- /src/routes/components/LiteYoutubeEmbed.svelte: -------------------------------------------------------------------------------- 1 | 26 | 27 | 28 | 29 |
    { 31 | iframeInitialized = true; 32 | }} 33 | class="notion-yt-lite {iframeInitialized ? 'notion-yt-initialized' : ''}" 34 | style={$$props.style} 35 | > 36 | 37 | 38 |
    39 | 40 | {#if iframeInitialized} 41 |