From fbb9bf209c9b54160f061838be38c76c8ad663a7 Mon Sep 17 00:00:00 2001 From: Alexander Wang Date: Tue, 10 Mar 2020 12:27:14 +0800 Subject: [PATCH 01/15] conf: Setup for TypeScript --- .eslintignore | 2 ++ .eslintrc.json | 60 +++++++++++++++++++++++++++++++++++++++++++----- LICENSE | 2 +- package.json | 18 ++++++++++++--- rollup.config.js | 18 +++++++++++++++ tsconfig.json | 30 ++++++++++++++++++++++++ 6 files changed, 120 insertions(+), 10 deletions(-) create mode 100644 .eslintignore create mode 100644 rollup.config.js create mode 100644 tsconfig.json diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..4171f3f --- /dev/null +++ b/.eslintignore @@ -0,0 +1,2 @@ +dist/* +test/* \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json index 77d6f8d..ac7f1be 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -5,17 +5,35 @@ "es6": true, "node": true }, - "extends": "eslint:recommended", + "parser": "@typescript-eslint/parser", + "plugins": [ + "@typescript-eslint" + ], + "extends": [ + "plugin:@typescript-eslint/recommended" + ], "globals": { "Atomics": "readonly", "SharedArrayBuffer": "readonly" }, "parserOptions": { - "ecmaVersion": 2018 + "ecmaVersion": 2018, + "sourceType": "module" }, "rules": { + "camelcase": "off", + "@typescript-eslint/camelcase": [ + "off" + ], "indent": [ - "error", + "warn", + 2, + { + "SwitchCase": 1 + } + ], + "@typescript-eslint/indent": [ + "warn", 2, { "SwitchCase": 1 @@ -27,12 +45,42 @@ ], "quotes": [ "error", - "single" + "double" ], "semi": [ "error", "never" ], - "no-console": "off" - } + "no-console": "off", + "@typescript-eslint/no-use-before-define": [ + "error", + { + "functions": false, + "classes": true + } + ], + "@typescript-eslint/member-delimiter-style": [ + "error", + { + "multiline": { + "delimiter": "none", + "requireLast": false + }, + "singleline": { + "delimiter": "semi", + "requireLast": false + } + } + ] + }, + "overrides": [ + { + "files": [ + "*.js" + ], + "rules": { + "@typescript-eslint/no-var-requires": "off" + } + } + ] } \ No newline at end of file diff --git a/LICENSE b/LICENSE index 5b8ba49..fda9c66 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2019-present Wen-Zhi Wang +Copyright (c) 2019-present Wen-Zhi Wang Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/package.json b/package.json index f999f8c..f44e893 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,11 @@ "notablog": "bin/cli.js" }, "scripts": { - "build": "npm run build:dependency-graph", + "build": "npm run build:module && npm run build:doc", + "build:module": "rm -rf dist && rollup -c && tsc --emitDeclarationOnly", + "build:doc": "npm run build:dependency-graph", "build:dependency-graph": "npx depcruise --exclude '^node_modules' --output-type dot --prefix 'https://github.com/dragonman225/notablog/tree/master/' src/index.js | dot -T svg > assets/deps_graph.svg", + "test": "ts-node test/index.spec.ts", "release": "npm run build && npm publish", "release:beta": "npm run build && npm publish --tag beta", "upgrade": "node tools/upgrade-deps.js" @@ -26,11 +29,20 @@ "squirrelly": "^7.9.2" }, "devDependencies": { + "@rollup/plugin-typescript": "^4.0.0", + "@types/node": "^13.9.0", + "@typescript-eslint/eslint-plugin": "^2.23.0", + "@typescript-eslint/parser": "^2.23.0", "dependency-cruiser": "^8.0.1", - "eslint": "^6.8.0" + "eslint": "^6.8.0", + "nast-types": "^1.0.0", + "rollup": "^2.0.2", + "ts-node": "^8.6.2", + "typescript": "^3.8.3", + "zora": "^3.1.8" }, "files": [ - "src/", + "dist/", "assets/" ], "keywords": [ diff --git a/rollup.config.js b/rollup.config.js new file mode 100644 index 0000000..a27ea54 --- /dev/null +++ b/rollup.config.js @@ -0,0 +1,18 @@ +import typescript from "@rollup/plugin-typescript" + +const pkg = require("./package.json") + +export default { + input: "src/index.ts", + output: [ + { + file: "dist/index.js", + format: "cjs" + }, { + file: "dist/index.esm.js", + format: "es" + } + ], + plugins: [typescript()], + external: [...Object.keys(pkg.dependencies)] +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..ae05c3b --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "outDir": "./dist", + "target": "esnext", + "module": "commonjs", + "lib": [ + "es7" + ], + "moduleResolution": "node", + "strict": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "removeComments": false, + "preserveConstEnums": true, + "sourceMap": true, + "declaration": true, + "declarationMap": true, + "noImplicitAny": false // to use untyped modules + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "test/**/*" + ] +} \ No newline at end of file From ec24fc2b750cd49beeb2d1a35ca21008ee1bdb3a Mon Sep 17 00:00:00 2001 From: Alexander Wang Date: Tue, 10 Mar 2020 15:05:36 +0800 Subject: [PATCH 02/15] refactor: Migrate to TypeScript --- bin/cli.js | 2 +- src/{generate.js => generate.ts} | 42 ++++----- src/index.js | 4 - src/index.ts | 4 + src/notion-utils.js | 87 ------------------- src/notion-utils.ts | 67 ++++++++++++++ src/{parse-table.js => parse-table.ts} | 74 +++------------- src/{preview.js => preview.ts} | 12 ++- src/{render-index.js => render-index.ts} | 14 ++- src/{render-post.js => render-post.ts} | 24 +++-- ...plate-provider.js => template-provider.ts} | 18 ++-- src/{util.js => util.ts} | 18 ++-- 12 files changed, 142 insertions(+), 224 deletions(-) rename src/{generate.js => generate.ts} (83%) delete mode 100644 src/index.js create mode 100644 src/index.ts delete mode 100644 src/notion-utils.js create mode 100644 src/notion-utils.ts rename src/{parse-table.js => parse-table.ts} (79%) rename src/{preview.js => preview.ts} (76%) rename src/{render-index.js => render-index.ts} (79%) rename src/{render-post.js => render-post.ts} (77%) rename src/{template-provider.js => template-provider.ts} (85%) rename src/{util.js => util.ts} (82%) diff --git a/bin/cli.js b/bin/cli.js index 6487b2c..881eb0f 100644 --- a/bin/cli.js +++ b/bin/cli.js @@ -25,7 +25,7 @@ async function cmdGenerate(opts, logger) { try { const startTime = Date.now() - await generate(opts) + await generate(opts.workDir, opts) const endTime = Date.now() const timeElapsed = (endTime - startTime) / 1000 logger.info(`\ diff --git a/src/generate.js b/src/generate.ts similarity index 83% rename from src/generate.js rename to src/generate.ts index eea5919..e040afd 100644 --- a/src/generate.js +++ b/src/generate.ts @@ -1,14 +1,18 @@ -const fs = require('fs') -const path = require('path') -const { createAgent } = require('notionapi-agent') -const { TaskManager2 } = require('@dnpr/task-manager') -const { copyDirSync } = require('@dnpr/fsutil') - -const TemplateProvider = require('./template-provider') -const { parseTable } = require('./parse-table') -const { renderIndex } = require('./render-index') -const { renderPost } = require('./render-post') -const { log, getConfig } = require('./util') +import fs from 'fs' +import path from 'path' +import { createAgent } from 'notionapi-agent' +import { TaskManager2 } from '@dnpr/task-manager' +import { copyDirSync } from '@dnpr/fsutil' +import { TemplateProvider } from './template-provider' +import { parseTable } from './parse-table' +import { renderIndex } from './render-index' +import { renderPost } from './render-post' +import { log, getConfig } from './util' + +type GenerateOptions = { + concurrency?: number + verbose?: boolean +} /** * Check if a page is newer than its cached version. @@ -38,11 +42,8 @@ function isPageUpdated(pageId, lastEditedTime, cacheDir) { /** * Generate a blog. - * @param {GenerateOptions} opts */ -async function generate(opts = {}) { - - const workDir = opts.workDir +export async function generate(workDir: string, opts: GenerateOptions = {}) { const concurrency = opts.concurrency const verbose = opts.verbose const notion = createAgent({ debug: verbose }) @@ -111,6 +112,9 @@ async function generate(opts = {}) { return data }, { pagesUpdated: [], pagesNotUpdated: [] + } as { + pagesUpdated: typeof siteMeta.pages, + pagesNotUpdated: typeof siteMeta.pages }) const pageTotalCount = siteMeta.pages.length @@ -134,7 +138,7 @@ async function generate(opts = {}) { ...dirs, doFetchPage: true } - }])) + }]) as never) }) pagesNotUpdated.forEach(page => { tasks.push(tm2.queue(renderPost, [{ @@ -148,10 +152,8 @@ async function generate(opts = {}) { ...dirs, doFetchPage: false } - }])) + }]) as never) }) await Promise.all(tasks) return 0 -} - -module.exports = { generate } \ No newline at end of file +} \ No newline at end of file diff --git a/src/index.js b/src/index.js deleted file mode 100644 index 1d77454..0000000 --- a/src/index.js +++ /dev/null @@ -1,4 +0,0 @@ -const { generate } = require('./generate') -const { preview } = require('./preview') - -module.exports = { generate, preview } \ No newline at end of file diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..43a3af2 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,4 @@ +import { generate } from './generate' +import { preview } from './preview' + +export { generate, preview } \ No newline at end of file diff --git a/src/notion-utils.js b/src/notion-utils.js deleted file mode 100644 index e9dda8d..0000000 --- a/src/notion-utils.js +++ /dev/null @@ -1,87 +0,0 @@ -module.exports = { - getPageIDFromNotionDatabaseURL, - getBookmarkLinkFromNotionPageURL, - getPageIDFromNotionPageURL, - toDashID, - isValidDashID, - convertNotionURLToLocalLink, // Deprecated - getPageIDfromNotionURL // Deprecated -} - -const dashIDLen = '0eeee000-cccc-bbbb-aaaa-123450000000'.length -const noDashIDLen = '0eeee000ccccbbbbaaaa123450000000'.length - -function getPageIDFromNotionDatabaseURL(str) { - let splitArr = str.split('/') - splitArr = splitArr.pop().split('-') - splitArr = splitArr.pop().split('?') - - let pageID = splitArr[0] - if (pageID && pageID.length === noDashIDLen) { - return toDashID(pageID) - } else { - throw new Error(`Cannot get pageID from ${str}`) - } -} - -function getBookmarkLinkFromNotionPageURL(str) { - let splitArr = str.split('/') - splitArr = splitArr.pop().split('-') - splitArr = splitArr.pop().split('#') - - let blockID = splitArr[1] - if (blockID && blockID.length === noDashIDLen) { - return `#${toDashID(blockID)}` - } else { - return str - } -} - -function getPageIDFromNotionPageURL(str) { - let splitArr = str.split('/') - splitArr = splitArr.pop().split('-') - - let pageID = splitArr.pop() - if (pageID && pageID.length === noDashIDLen) { - return toDashID(pageID) - } else { - throw new Error(`Cannot get pageID from ${str}`) - } -} - -function toDashID(str) { - if (isValidDashID(str)) { - return str - } - - let s = str.replace(/-/g, '') - - if (s.length !== noDashIDLen) { - return str - } - - let res = str.substring(0, 8) + '-' + str.substring(8, 12) + '-' + str.substring(12, 16) + '-' + str.substring(16, 20) + '-' + str.substring(20) - return res -} - -function isValidDashID(str) { - if (str.length !== dashIDLen) { - return false - } - - if (str.indexOf('-') === -1) { - return false - } - - return true -} - -/** Deprecated. Please use getBookmarkLinkfromNotionPageURL() instead. */ -function convertNotionURLToLocalLink(str) { - return getBookmarkLinkFromNotionPageURL(str) -} - -/** Deprecated. Please use getPageIDFromNotionPageURL() instead. */ -function getPageIDfromNotionURL(str) { - return getPageIDFromNotionPageURL(str) -} \ No newline at end of file diff --git a/src/notion-utils.ts b/src/notion-utils.ts new file mode 100644 index 0000000..8c8dea4 --- /dev/null +++ b/src/notion-utils.ts @@ -0,0 +1,67 @@ +const dashIDLen = '0eeee000-cccc-bbbb-aaaa-123450000000'.length +const noDashIDLen = '0eeee000ccccbbbbaaaa123450000000'.length + +export function getPageIDFromPageURL(str: string): string { + let splitArr = str.split('/') + splitArr = (splitArr.pop() || "").split('-') + + let pageID = splitArr.pop() + if (pageID && pageID.length === noDashIDLen) { + return toDashID(pageID) + } else { + throw new Error(`Cannot get pageID from ${str}`) + } +} + +export function getPageIDFromCollectionPageURL(str: string): string { + let splitArr = str.split('/') + splitArr = (splitArr.pop() || "").split('-') + splitArr = (splitArr.pop() || "").split('?') + + let pageID = splitArr[0] + if (pageID && pageID.length === noDashIDLen) { + return toDashID(pageID) + } else { + throw new Error(`Cannot get pageID from ${str}`) + } +} + +export function getBookmarkLinkFromPageURL(str: string): string { + let splitArr = str.split('/') + splitArr = (splitArr.pop() || "").split('-') + splitArr = (splitArr.pop() || "").split('#') + + let blockID = splitArr[1] + if (blockID && blockID.length === noDashIDLen) { + return `#${toDashID(blockID)}` + } else { + return str + } +} + +export function toDashID(str: string): string { + if (isValidDashID(str)) { + return str + } + + let s = str.replace(/-/g, '') + if (s.length !== noDashIDLen) { + return str + } + + let res = + str.substring(0, 8) + '-' + str.substring(8, 12) + '-' + + str.substring(12, 16) + '-' + str.substring(16, 20) + '-' + + str.substring(20) + return res +} + +export function isValidDashID(str: string): boolean { + if (str.length !== dashIDLen) { + return false + } + if (str.indexOf('-') === -1) { + return false + } + return true +} \ No newline at end of file diff --git a/src/parse-table.js b/src/parse-table.ts similarity index 79% rename from src/parse-table.js rename to src/parse-table.ts index a49f0c0..27b87fa 100644 --- a/src/parse-table.js +++ b/src/parse-table.ts @@ -1,21 +1,14 @@ -const { getOnePageAsTree } = require('nast-util-from-notionapi') -const { renderToHTML } = require('nast-util-to-react') - -const { getPageIDFromNotionDatabaseURL } = require('./notion-utils') -const { log } = require('./util') - -module.exports = { - parseTable -} +import { getOnePageAsTree } from 'nast-util-from-notionapi' +import { renderToHTML } from 'nast-util-to-react' +import { getPageIDFromCollectionPageURL } from './notion-utils' +import { log } from './util' /** * Extract interested data for blog generation from a Notion table. - * @param {string} notionDatabaseURL - * @param {NotionAgent} notionAgent */ -async function parseTable(notionDatabaseURL, notionAgent) { - let pageID = getPageIDFromNotionDatabaseURL(notionDatabaseURL) - let pageCollection = (await getOnePageAsTree(pageID, notionAgent)) +export async function parseTable(collectionPageURL, notionAgent) { + let pageID = getPageIDFromCollectionPageURL(collectionPageURL) + let pageCollection = (await getOnePageAsTree(pageID, notionAgent)) as NAST.CollectionPage /** * Create map for property_name -> property_id. @@ -51,8 +44,8 @@ column with id "${propertyId}" is used`) * Create map for tag -> color */ let tagColorMap = {} - let classPrefix = '' - pageCollection.schema[schemaMap['tags']].options.forEach(tag => { + let classPrefix = ''; + (pageCollection.schema[schemaMap['tags']].options || []).forEach(tag => { tagColorMap[tag.value] = `${classPrefix}${tag.color}` }) @@ -60,42 +53,11 @@ column with id "${propertyId}" is used`) let pagesValid = pageCollection.children .filter(page => page.properties) - /** - * Select Option - * @typedef {Object} SelectOption - * @property {string} value - * @property {string} color - */ - /** - * Metadata of a page - * @typedef {Object} PageMetadata - * @property {string} id - * @property {string} icon - * @property {string} iconHTML - * @property {string} cover - * @property {string} title - * @property {SelectOption[]} tags - * @property {boolean} publish - * @property {boolean} inMenu - * @property {boolean} inList - * @property {string} template - * @property {string} url - * @property {Notion.StyledString[]} description - * @property {string} descriptionPlain - * @property {string} descriptionHTML - * @property {string} date - * @property {string} dateString - * @property {number} createdTime - * @property {number} lastEditedTime - */ - /** - * @type {PageMetadata[]} - */ let pagesConverted = pagesValid .map(row => { return { - id: row.uri.split('/').pop().split('?')[0], + id: (row.uri.split('/').pop() || "").split('?')[0], icon: row.icon, iconHTML: renderIconToHTML(row.icon), cover: row.cover, @@ -121,22 +83,6 @@ column with id "${propertyId}" is used`) } }) - /** - * The site metadata - * @typedef {Object} SiteMetadata - * @property {string} icon - * @property {string} iconHTML - * @property {string} cover - * @property {string} title - * @property {Notion.StyledString[]} description - * @property {string} descriptionPlain - * @property {string} descriptionHTML - * @property {PageMetadata[]} pages - * @property {Map} tagMap - */ - /** - * @type {SiteMetadata} - */ let siteMeta = { icon: pageCollection.icon, iconHTML: renderIconToHTML(pageCollection.icon), diff --git a/src/preview.js b/src/preview.ts similarity index 76% rename from src/preview.js rename to src/preview.ts index 525b7b0..354986c 100644 --- a/src/preview.js +++ b/src/preview.ts @@ -1,6 +1,6 @@ -const path = require('path') -const { spawn } = require('child_process') -const { getConfig, outDir } = require('./util') +import path from 'path' +import { spawn } from 'child_process' +import { getConfig, outDir } from './util' /** * Open `index` with `bin`. @@ -17,13 +17,11 @@ function open(bin, index) { * Preview the generate blog. * @param {string} workDir */ -function preview(workDir) { +export function preview(workDir) { const c = getConfig(workDir) if (c.previewBrowser) { open(c.previewBrowser, path.join(outDir(workDir), 'index.html')) } else { throw new Error('"previewBrowser" property is not set in your Notablog config file.') } -} - -module.exports = { preview } \ No newline at end of file +} \ No newline at end of file diff --git a/src/render-index.js b/src/render-index.ts similarity index 79% rename from src/render-index.js rename to src/render-index.ts index af3e80a..5fcaf9c 100644 --- a/src/render-index.js +++ b/src/render-index.ts @@ -1,14 +1,10 @@ -const fs = require('fs') -const path = require('path') -const Sqrl = require('squirrelly') +import fs from 'fs' +import path from 'path' +import Sqrl from 'squirrelly' -const { log } = require('./util') +import { log } from './util' -module.exports = { - renderIndex -} - -function renderIndex(task) { +export function renderIndex(task) { const siteMeta = task.data.siteMeta const templateProvider = task.tools.templateProvider const config = task.config diff --git a/src/render-post.js b/src/render-post.ts similarity index 77% rename from src/render-post.js rename to src/render-post.ts index 075b818..6cab65b 100644 --- a/src/render-post.js +++ b/src/render-post.ts @@ -1,22 +1,18 @@ -const fs = require('fs') -const fsPromises = fs.promises -const path = require('path') -const { getOnePageAsTree } = require('nast-util-from-notionapi') -const { renderToHTML } = require('nast-util-to-react') -const Sqrl = require('squirrelly') +import fs from 'fs' +import fsPromises = fs.promises +import path from 'path' +import { getOnePageAsTree } from 'nast-util-from-notionapi' +import { renderToHTML } from 'nast-util-to-react' +import Sqrl from 'squirrelly' -const { log, parseJSON } = require('./util') -const { toDashID } = require('./notion-utils') - -module.exports = { - renderPost -} +import { log, parseJSON } from './util' +import { toDashID } from './notion-utils' /** * Render a post. * @param {RenderPostTask} task */ -async function renderPost(task) { +export async function renderPost(task) { const siteMeta = task.data.siteMeta const templateProvider = task.tools.templateProvider @@ -50,7 +46,7 @@ Cache of page "${pageID}" is corrupted, delete source/notion_cache to rebuild`) /** Render with template. */ if (page.publish) { log.info(`Render page "${pageID}"`) - contentHTML = renderToHTML(nast, { contentOnly: true }) + contentHTML = renderToHTML(nast) const outDir = config.outDir const postPath = path.join(outDir, page.url) diff --git a/src/template-provider.js b/src/template-provider.ts similarity index 85% rename from src/template-provider.js rename to src/template-provider.ts index 87b418f..87145f9 100644 --- a/src/template-provider.js +++ b/src/template-provider.ts @@ -1,9 +1,13 @@ -const path = require('path') -const fs = require('fs') +import path from 'path' +import fs from 'fs' -const { log } = require('./util') +import { log } from './util' -class TemplateProvider { +export class TemplateProvider { + private templateDir: string + private templateMap: { + [key: string]: string + } /** * @param {string} themeDir @@ -36,7 +40,7 @@ class TemplateProvider { * If loading failed, return an error message string. * @param {string} templateName */ - _load(templateName) { + private _load(templateName) { log.debug(`Load template "${templateName}"`) let templatePath = path.join(this.templateDir, `${templateName}.html`) try { @@ -51,6 +55,4 @@ class TemplateProvider { return 'Template name has length 0, please check "template" field of your table on Notion' } } -} - -module.exports = TemplateProvider \ No newline at end of file +} \ No newline at end of file diff --git a/src/util.js b/src/util.ts similarity index 82% rename from src/util.js rename to src/util.ts index 987eaeb..3b313e6 100644 --- a/src/util.js +++ b/src/util.ts @@ -1,11 +1,11 @@ -const fs = require('fs') -const path = require('path') -const { Logger } = require('@dnpr/logger') +import fs from 'fs' +import path from 'path' +import { Logger } from '@dnpr/logger' /** * Wrapper of console.log(). */ -const log = new Logger('notablog', { +export const log = new Logger('notablog', { logLevel: typeof process.env.DEBUG_EN !== 'undefined' ? 'debug' : 'info', useColor: typeof process.env.NO_COLOR !== 'undefined' ? false : true }) @@ -15,7 +15,7 @@ const log = new Logger('notablog', { * @param {*} str - Payload to parse. * @returns {Object} Parsed object when success, undefined when fail. */ -function parseJSON(str) { +export function parseJSON(str) { try { return JSON.parse(str) } catch (error) { @@ -35,7 +35,7 @@ function parseJSON(str) { * @param {string} workDir - A valid Notablog starter directory. * @returns {NotablogConfig} */ -function getConfig(workDir) { +export function getConfig(workDir) { const cPath = path.join(workDir, 'config.json') const cFile = fs.readFileSync(cPath, { encoding: 'utf-8' }) try { @@ -52,12 +52,10 @@ function getConfig(workDir) { * @param {string} workDir * @returns {string} */ -function outDir(workDir) { +export function outDir(workDir) { const outDir = path.join(workDir, 'public') if (!fs.existsSync(outDir)) { fs.mkdirSync(outDir, { recursive: true }) } return outDir -} - -module.exports = { log, parseJSON, getConfig, outDir } \ No newline at end of file +} \ No newline at end of file From a33a78f0678c18b07a870d3e307fb4ccd965b558 Mon Sep 17 00:00:00 2001 From: Alexander Wang Date: Tue, 10 Mar 2020 15:07:02 +0800 Subject: [PATCH 03/15] fix: Update scripts and deps * @rollup/plugin-typescript v4.0.0 is broken, so fallback to v2.1.0 --- package.json | 9 +++++---- rollup.config.js | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index f44e893..39b6b50 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "description": "Generate a minimalistic blog from a Notion.so table.", "author": "dragonman225", "license": "MIT", - "main": "src/index.js", + "main": "dist/index.js", + "types": "dist/index.d.ts", "bin": { "notablog": "bin/cli.js" }, @@ -12,7 +13,7 @@ "build": "npm run build:module && npm run build:doc", "build:module": "rm -rf dist && rollup -c && tsc --emitDeclarationOnly", "build:doc": "npm run build:dependency-graph", - "build:dependency-graph": "npx depcruise --exclude '^node_modules' --output-type dot --prefix 'https://github.com/dragonman225/notablog/tree/master/' src/index.js | dot -T svg > assets/deps_graph.svg", + "build:dependency-graph": "npx depcruise --exclude '^node_modules' --output-type dot --prefix 'https://github.com/dragonman225/notablog/tree/master/' src/index.ts | dot -T svg > assets/deps_graph.svg", "test": "ts-node test/index.spec.ts", "release": "npm run build && npm publish", "release:beta": "npm run build && npm publish --tag beta", @@ -29,13 +30,13 @@ "squirrelly": "^7.9.2" }, "devDependencies": { - "@rollup/plugin-typescript": "^4.0.0", + "@rollup/plugin-typescript": "^2.1.0", "@types/node": "^13.9.0", "@typescript-eslint/eslint-plugin": "^2.23.0", "@typescript-eslint/parser": "^2.23.0", "dependency-cruiser": "^8.0.1", "eslint": "^6.8.0", - "nast-types": "^1.0.0", + "nast-types": "^1.1.0", "rollup": "^2.0.2", "ts-node": "^8.6.2", "typescript": "^3.8.3", diff --git a/rollup.config.js b/rollup.config.js index a27ea54..a2baa64 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -14,5 +14,5 @@ export default { } ], plugins: [typescript()], - external: [...Object.keys(pkg.dependencies)] + external: [...Object.keys(pkg.dependencies), "fs", "path", "child_process"] } \ No newline at end of file From 4f7262be7eb5eb59e45fda49f4ca9821a691d40f Mon Sep 17 00:00:00 2001 From: Alexander Wang Date: Wed, 11 Mar 2020 22:10:04 +0800 Subject: [PATCH 04/15] conf: Add "npm run dev" Since the project is now written in TypeScript, the sources have to be compiled before using the CLI. Manually running the build script isn't fun, so I add this NPM script that executes `rollup -cw` to automatically rebuild the bundle whenever sources are modified. --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 39b6b50..c93356b 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "build:module": "rm -rf dist && rollup -c && tsc --emitDeclarationOnly", "build:doc": "npm run build:dependency-graph", "build:dependency-graph": "npx depcruise --exclude '^node_modules' --output-type dot --prefix 'https://github.com/dragonman225/notablog/tree/master/' src/index.ts | dot -T svg > assets/deps_graph.svg", + "dev": "rollup -cw", "test": "ts-node test/index.spec.ts", "release": "npm run build && npm publish", "release:beta": "npm run build && npm publish --tag beta", @@ -36,7 +37,7 @@ "@typescript-eslint/parser": "^2.23.0", "dependency-cruiser": "^8.0.1", "eslint": "^6.8.0", - "nast-types": "^1.1.0", + "nast-types": "^1.1.1", "rollup": "^2.0.2", "ts-node": "^8.6.2", "typescript": "^3.8.3", From 49e3cf5db0f0e4356f2d7500e073aa96107ae554 Mon Sep 17 00:00:00 2001 From: Alexander Wang Date: Fri, 13 Mar 2020 23:30:00 +0800 Subject: [PATCH 05/15] feat: Introduce NTable, a runtime lib for NAST.Collection NAST.Collection is a plain JS object, but it is not easy to work with, NTable solve the problem by creating a class to represent the collection and provide lots of pointers that links related data for easier access. --- .vscode/launch.json | 14 -- assets/deps_graph.svg | 322 +++++++++++++++++++++--------------------- package.json | 4 +- src/ntable.ts | 242 +++++++++++++++++++++++++++++++ src/table.ts | 258 +++++++++++++++++++++++++++++++++ src/util.ts | 11 ++ test/ntable.spec.ts | 32 +++++ 7 files changed, 707 insertions(+), 176 deletions(-) delete mode 100644 .vscode/launch.json create mode 100644 src/ntable.ts create mode 100644 src/table.ts create mode 100644 test/ntable.spec.ts diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index a4b5bd3..0000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - { - "type": "node", - "request": "launch", - "name": "Launch Program", - "program": "${workspaceFolder}/index.js" - } - ] -} \ No newline at end of file diff --git a/assets/deps_graph.svg b/assets/deps_graph.svg index 2f7dde0..a28f1d6 100644 --- a/assets/deps_graph.svg +++ b/assets/deps_graph.svg @@ -4,270 +4,270 @@ - - + + dependency-cruiser output - + cluster_src - -src + +src child_process - -child_process + +child_process fs - -fs + +fs path - -path + +path - + -src/generate.js - - -generate.js +src/generate.ts + + +generate.ts - + -src/generate.js->fs - - +src/generate.ts->fs + + - + -src/generate.js->path - - +src/generate.ts->path + + - + -src/parse-table.js - - -parse-table.js +src/parse-table.ts + + +parse-table.ts - + -src/generate.js->src/parse-table.js - - +src/generate.ts->src/parse-table.ts + + - + -src/render-index.js - - -render-index.js +src/render-index.ts + + +render-index.ts - + -src/generate.js->src/render-index.js - - +src/generate.ts->src/render-index.ts + + - + -src/render-post.js - - -render-post.js +src/render-post.ts + + +render-post.ts - + -src/generate.js->src/render-post.js - - +src/generate.ts->src/render-post.ts + + - + -src/template-provider.js - - -template-provider.js +src/template-provider.ts + + +template-provider.ts - + -src/generate.js->src/template-provider.js - - +src/generate.ts->src/template-provider.ts + + - + -src/util.js - - -util.js +src/util.ts + + +util.ts - + -src/generate.js->src/util.js - - +src/generate.ts->src/util.ts + + - + -src/parse-table.js->src/util.js - - +src/parse-table.ts->src/util.ts + + - + -src/notion-utils.js - - -notion-utils.js +src/notion-utils.ts + + +notion-utils.ts - + -src/parse-table.js->src/notion-utils.js - - +src/parse-table.ts->src/notion-utils.ts + + - + -src/render-index.js->fs - - +src/render-index.ts->fs + + - + -src/render-index.js->path - - +src/render-index.ts->path + + - + -src/render-index.js->src/util.js - - +src/render-index.ts->src/util.ts + + - + -src/render-post.js->fs - - +src/render-post.ts->fs + + - + -src/render-post.js->path - - +src/render-post.ts->path + + - + -src/render-post.js->src/util.js - - +src/render-post.ts->src/util.ts + + - + -src/render-post.js->src/notion-utils.js - - +src/render-post.ts->src/notion-utils.ts + + - + -src/template-provider.js->fs - - +src/template-provider.ts->fs + + - + -src/template-provider.js->path - - +src/template-provider.ts->path + + - + -src/template-provider.js->src/util.js - - +src/template-provider.ts->src/util.ts + + - + -src/util.js->fs - - +src/util.ts->fs + + - + -src/util.js->path - - +src/util.ts->path + + - + -src/index.js - - -index.js +src/index.ts + + +index.ts - + -src/index.js->src/generate.js - - +src/index.ts->src/generate.ts + + - + -src/preview.js - - -preview.js +src/preview.ts + + +preview.ts - + -src/index.js->src/preview.js - - +src/index.ts->src/preview.ts + + - + -src/preview.js->child_process - - +src/preview.ts->child_process + + - + -src/preview.js->path - - +src/preview.ts->path + + - + -src/preview.js->src/util.js - - +src/preview.ts->src/util.ts + + diff --git a/package.json b/package.json index c93356b..17380af 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,9 @@ "build:module": "rm -rf dist && rollup -c && tsc --emitDeclarationOnly", "build:doc": "npm run build:dependency-graph", "build:dependency-graph": "npx depcruise --exclude '^node_modules' --output-type dot --prefix 'https://github.com/dragonman225/notablog/tree/master/' src/index.ts | dot -T svg > assets/deps_graph.svg", + "build:tsviz": "tsviz src/ntable.ts assets/ntable.png", "dev": "rollup -cw", - "test": "ts-node test/index.spec.ts", + "test": "ts-node test/ntable.spec.ts", "release": "npm run build && npm publish", "release:beta": "npm run build && npm publish --tag beta", "upgrade": "node tools/upgrade-deps.js" @@ -40,6 +41,7 @@ "nast-types": "^1.1.1", "rollup": "^2.0.2", "ts-node": "^8.6.2", + "tsviz": "^1.0.11", "typescript": "^3.8.3", "zora": "^3.1.8" }, diff --git a/src/ntable.ts b/src/ntable.ts new file mode 100644 index 0000000..43b9090 --- /dev/null +++ b/src/ntable.ts @@ -0,0 +1,242 @@ +import { getPageIDFromPageURL } from "./notion-utils" + +interface IProperty { + id: string + type: string + records: Map +} + +interface IRecord { + id: string + properties: Map +} + +interface ICell { + property: IProperty + record: IRecord + value: any +} + +interface ITable { + id: string + schema: IProperty[] + records: IRecord[] +} + +class NTextProperty implements IProperty { + id: string + type: "text" + records: Map + + /** Extended properties. */ + name: string + + constructor(id: string, rawProperty: Notion.Collection.ColumnProperty) { + this.id = id + this.name = rawProperty.name + this.type = "text" + this.records = new Map() + } +} + +class NCheckboxProperty implements IProperty { + id: string + type: "checkbox" + records: Map + + /** Extended properties. */ + name: string + + constructor(id: string, rawProperty: Notion.Collection.ColumnProperty) { + this.id = id + this.name = rawProperty.name + this.type = "checkbox" + this.records = new Map() + } +} + +type SelectOption = { + id: string + color: Notion.Collection.ColumnPropertyOptionColor + value: string +} + +class NSelectProperty implements IProperty { + id: string + type: "select" + records: Map + + /** Extended properties. */ + name: string + options: SelectOption[] + + constructor(id: string, rawProperty: Notion.Collection.ColumnProperty) { + this.id = id + this.name = rawProperty.name + this.type = "select" + this.records = new Map() + this.options = rawProperty.options || [] + } +} + +type NProperty = NTextProperty | NCheckboxProperty | NSelectProperty + +class NPageRecord implements IRecord { + id: string + properties: Map + + /** Extended properties. */ + uri: NAST.URI + title: NAST.SemanticString[] + icon?: NAST.Emoji | NAST.PublicUrl + cover?: NAST.PublicUrl + coverPosition: number + fullWidth: boolean + + constructor(rawPage: NAST.Page) { + this.id = getPageIDFromPageURL(rawPage.uri) + this.properties = new Map() + + this.uri = rawPage.uri + this.title = rawPage.title + this.icon = rawPage.icon + this.cover = rawPage.cover + this.coverPosition = rawPage.coverPosition + this.fullWidth = rawPage.fullWidth + } +} + +class NTextCell implements ICell { + property: NTextProperty + record: NPageRecord + value: NAST.SemanticString[] + + constructor(property: NTextProperty, record: NPageRecord, + rawValue: NAST.SemanticString[]) { + this.property = property + this.record = record + this.value = rawValue + } +} + +class NCheckboxCell implements ICell { + property: NCheckboxProperty + record: NPageRecord + value: boolean + + constructor(property: NCheckboxProperty, record: NPageRecord, + rawValue: NAST.SemanticString[]) { + this.property = property + this.record = record + this.value = rawValue ? rawValue[0][0] === "Yes" : false + } +} + +class NSelectCell implements ICell { + property: NSelectProperty + record: NPageRecord + value: SelectOption | undefined + + constructor(property: NSelectProperty, record: NPageRecord, + rawValue: NAST.SemanticString[]) { + this.property = property + this.record = record + + const optionNames = rawValue ? rawValue[0][0].split(",") : [] + this.value = property.options.find(o => o.value === optionNames[0]) + } +} + +type NCell = NTextCell | NCheckboxCell | NSelectCell + +export class NTable implements ITable { + id: string + schema: NProperty[] + records: NPageRecord[] + + constructor(rawTable: NAST.Collection) { + this.id = getPageIDFromPageURL(rawTable.uri) + + const rawTableColumnProps = rawTable.views[0].format.table_properties + /** + * Using rawTableColumnProps to initialize schema make the order of + * properties match the order of columns in the UI. + */ + if (rawTableColumnProps) { + this.schema = rawTableColumnProps + /** Filter out properties that do not exist in schema. */ + .filter(tableProperty => rawTable.schema[tableProperty.property]) + .map(tableProperty => { + const propertyId = tableProperty.property + const rawProperty = rawTable.schema[propertyId] + return createNProperty(propertyId, rawProperty) + }) + } else { + this.schema = Object.entries(rawTable.schema).map(tuple => { + const [propertyId, rawProperty] = tuple + return createNProperty(propertyId, rawProperty) + }) + } + + this.records = [] + rawTable.children.forEach(rawPage => { + const record = new NPageRecord(rawPage) + this.records.push(record) + this.schema.forEach(property => { + const rawPropertyValue = (rawPage.properties || {})[property.id] + const cell = createNCell(property, record, rawPropertyValue) + property.records.set(record, cell) + record.properties.set(property, cell) + }) + }) + } + + /** Print the table structure so you can see what it looks like. */ + peekStructure() { + let head = "" + for (let i = 0; i < this.schema.length; i++) { + head += this.schema[i].constructor.name + " " + } + console.log(head) + console.log("".padEnd(head.length, "-")) + for (let i = 0; i < this.records.length; i++) { + const record = this.records[i] + let row = "" + record.properties.forEach((cell, property) => { + row += cell.constructor.name + .padEnd(property.constructor.name.length) + " " + }) + row += "-> " + record.constructor.name + console.log(row) + } + } +} + +function createNProperty( + propertyId: Notion.Collection.ColumnID, + rawProperty: Notion.Collection.ColumnProperty +) { + switch (rawProperty.type) { + case "checkbox": + return new NCheckboxProperty(propertyId, rawProperty) + case "select": + return new NSelectProperty(propertyId, rawProperty) + default: + return new NTextProperty(propertyId, rawProperty) + } +} + +function createNCell( + property: NProperty, + record: NPageRecord, + rawValue: NAST.SemanticString[] +) { + switch (property.type) { + case "checkbox": + return new NCheckboxCell(property, record, rawValue) + case "select": + return new NSelectCell(property, record, rawValue) + default: + return new NTextCell(property, record, rawValue) + } +} \ No newline at end of file diff --git a/src/table.ts b/src/table.ts new file mode 100644 index 0000000..ccbac53 --- /dev/null +++ b/src/table.ts @@ -0,0 +1,258 @@ +import { numToOrder } from "./util" + +interface PropertyValue { + type: Notion.Collection.ColumnPropertyType + value: any, + groupKeys?: string[] +} + +function parsePropertyValue( + column: Notion.Collection.ColumnProperty, + columnId: Notion.Collection.ColumnID, + row: NAST.Page +): PropertyValue { + /** + * A common place to get the column value. + * However, some types of column values are at other places. + */ + const data = (row.properties || {})[columnId] + switch (column.type) { + case "title": + return { + type: column.type, + value: row.title + } + case "checkbox": + return { + type: column.type, + value: data ? data[0][0] === "Yes" : false, + groupKeys: [data ? (data[0][0] === "Yes").toString() : "false"] + } + case "select": + case "multi_select": { + const optionNames = data ? data[0][0].split(",") : [] + const optionVals = optionNames.map(optionName => { + const option = (column.options || []) + .find(o => o.value === optionName) + if (!option) { + console.log(`Select option "${optionName}" is \ +not found on property "${columnId}:${column.name}".`) + return { + color: "default", + value: optionName + } + } else { + return { + color: option.color, + value: option.value + } + } + }) + return { + type: column.type, + value: optionVals, + groupKeys: optionVals.map(o => o.value) + } + } + // TODO: NAST currently do not have the following 2 information. + case "created_by": + case "last_edited_by": + return { + type: column.type, + value: "Someone" + } + case "created_time": + return { + type: column.type, + value: row.createdTime + } + case "last_edited_time": + return { + type: column.type, + value: row.lastEditedTime + } + default: + return { + type: column.type, + value: (row.properties || {})[columnId] + } + } +} + +export interface ISchema { + idPropertyMap: { + [key in Notion.Collection.ColumnID]: Notion.Collection.ColumnProperty + } + nameIdsMap: { + [key in Notion.Collection.ColumnProperty["name"]]: Notion.Collection.ColumnID[] + } + lookupIdsByName: (name: string) => Notion.Collection.ColumnID[] +} + +export interface IRecord { + uri: NAST.URI + title: NAST.SemanticString[] + icon?: NAST.Emoji | NAST.PublicUrl + cover?: NAST.PublicUrl + coverPosition: number + fullWidth: boolean + schema: Schema + getPropertyValueById: (id: Notion.Collection.ColumnID) => PropertyValue | undefined + getPropertyValuesByName: (name: Notion.Collection.ColumnProperty["name"]) => PropertyValue[] +} + +export interface ITable { + uri: NAST.URI + name: NAST.SemanticString[] + description?: NAST.SemanticString[] + icon?: NAST.Emoji | NAST.PublicUrl + cover?: NAST.PublicUrl + coverPosition: number + schema: Schema + records: Record[] +} + +export class Schema implements ISchema { + idPropertyMap: { + [key in Notion.Collection.ColumnID]: Notion.Collection.ColumnProperty + } + nameIdsMap: { + [key in Notion.Collection.ColumnProperty["name"]]: Notion.Collection.ColumnID[] + } + + constructor(rawSchema: NAST.Collection["schema"]) { + this.idPropertyMap = rawSchema + this.nameIdsMap = Object.entries(rawSchema).reduce((map, pair) => { + const propertyId = pair[0] + const propertyName = pair[1].name + if (map[propertyName] && Array.isArray(map[propertyName])) { + map[propertyName].push(propertyId) + } else { + map[propertyName] = [propertyId] + } + return map + }, {}) + } + + lookupIdsByName(name: string) { + const ids = this.nameIdsMap[name] + if (ids) return ids + else return [] + } +} + +export class Record implements IRecord { + uri: NAST.URI + title: NAST.SemanticString[] + icon?: NAST.Emoji | NAST.PublicUrl + cover?: NAST.PublicUrl + coverPosition: number + fullWidth: boolean + schema: Schema + private _rawRecord: NAST.Page + + constructor(rawRecord: NAST.Page, schema: Schema) { + this.uri = rawRecord.uri + this.title = rawRecord.title + this.icon = rawRecord.icon + this.cover = rawRecord.cover + this.coverPosition = rawRecord.coverPosition + this.fullWidth = rawRecord.fullWidth + this.schema = schema + this._rawRecord = rawRecord + } + + /** A property has an unique id. */ + getPropertyValueById(id: Notion.Collection.ColumnID) { + const property = this.schema.idPropertyMap[id] + if (property) + return parsePropertyValue(property, id, this._rawRecord) + else + return undefined + } + + /** Properties may have the same name. */ + getPropertyValuesByName(name: Notion.Collection.ColumnProperty["name"]) { + const propertyIds = this.schema.lookupIdsByName(name) + const propertyValues = propertyIds.reduce((pvs, id) => { + const pv = this.getPropertyValueById(id) + if (pv) pvs.push(pv) + return pvs + }, [] as PropertyValue[]) + return propertyValues + } +} + +export class Table implements ITable { + uri: NAST.URI + name: NAST.SemanticString[] + description?: NAST.SemanticString[] + icon?: NAST.Emoji | NAST.PublicUrl + cover?: NAST.PublicUrl + coverPosition: number + schema: Schema + records: Record[] + + constructor(rawTable: NAST.CollectionPage) { + this.uri = rawTable.uri + this.name = rawTable.name + this.description = rawTable.description + this.icon = rawTable.icon + this.cover = rawTable.cover + this.coverPosition = rawTable.coverPosition + this.schema = new Schema(rawTable.schema) + this.records = rawTable.children + .map(record => new Record(record, this.schema)) + } + + recordsGroupByProperty(propertyName: string, which: number = 1) { + const groups: { [key: string]: Record[] } = {} + const ungrouped: Record[] = [] + const propertyIds = this.schema.lookupIdsByName(propertyName) + const selectedPropertyId = propertyIds[which - 1] + if (!selectedPropertyId) { + console.log(`\ +Cannot find property of name "${propertyName}" that is the \ +${numToOrder(which)} of properties having the name in schema. +Return all records as ungrouped.`) + return { groups, ungrouped: this.records } + } + + for (let i = 0; i < this.records.length; i++) { + const record = this.records[i] + const propertyValue = + record.getPropertyValueById(selectedPropertyId) + if (!propertyValue) { + console.log(`\ +Cannot get property value on record \ +"${record.title.reduce((str, ss) => str += ss[0], "")}", \ +but the requested property exists in schema. +Is the table broken?`) + ungrouped.push(record) + continue + } + + const groupKeys = propertyValue.groupKeys + if (!groupKeys) { + console.log(`\ +Cannot group records by property name "${propertyName}" of type \ +"${propertyValue.type}".`) + ungrouped.push(record) + continue + } + + groupKeys.forEach(key => { + if (groups[key]) { + groups[key].push(record) + } else { + groups[key] = [record] + } + }) + } + + return { + groups, ungrouped + } + } +} + diff --git a/src/util.ts b/src/util.ts index 3b313e6..d9b6caa 100644 --- a/src/util.ts +++ b/src/util.ts @@ -58,4 +58,15 @@ export function outDir(workDir) { fs.mkdirSync(outDir, { recursive: true }) } return outDir +} + +export function numToOrder(n: number) { + switch (n) { + case 1: + return "1st" + case 2: + return "2nd" + default: + return `${n}th` + } } \ No newline at end of file diff --git a/test/ntable.spec.ts b/test/ntable.spec.ts new file mode 100644 index 0000000..203ba96 --- /dev/null +++ b/test/ntable.spec.ts @@ -0,0 +1,32 @@ +import { createAgent } from "notionapi-agent" +import { getOnePageAsTree } from "nast-util-from-notionapi" +import { getPageIDFromCollectionPageURL } from "../src/notion-utils" +import { Table } from "../src/table" +import { NTable } from "../src/ntable" + +async function main() { + try { + const pageId = getPageIDFromCollectionPageURL("https://www.notion.so/595365eeed0845fb9f4d641b7b845726?v=a1cb648704784afea1d5cdfb8ac2e9f0") + const collectionPage = (await getOnePageAsTree(pageId, createAgent())) as NAST.CollectionPage + const table = new Table(collectionPage) + const table2 = new NTable(collectionPage) + table.records.forEach(record => { + console.log(record.title) + console.log(record.getPropertyValuesByName("Tag")) + console.dir(record.getPropertyValuesByName("tags"), { depth: 100 }) + }) + console.dir(table.recordsGroupByProperty("Tag"), { depth: 2 }) + console.dir(table.recordsGroupByProperty("tags", 2), { depth: 2 }) + console.dir(table.recordsGroupByProperty("tags"), { depth: 2 }) + console.dir(table.recordsGroupByProperty("title"), { depth: 2 }) + console.dir(table.recordsGroupByProperty("publish"), { depth: 2 }) + console.dir(table.recordsGroupByProperty("template"), { depth: 2 }) + console.dir(table2.id, { depth: 2 }) + console.dir(table2.records[0].properties.get(table2.schema[0]), { depth: 2 }) + table2.peekStructure() + } catch (error) { + console.error(error) + } +} + +main() \ No newline at end of file From bb05cafd257cbecb1b8ebacdb05f477fc26895f6 Mon Sep 17 00:00:00 2001 From: Alexander Wang Date: Sun, 15 Mar 2020 01:49:26 +0800 Subject: [PATCH 06/15] feat: Add Cache module Cache module owns cache/ in a Notablog starter. It handles cache r/w and shouldUpdate() query. --- rollup.config.js | 2 +- src/cache.ts | 76 ++++++++++++++++++++++++++++++++++++++++ test/cache/cache.spec.ts | 40 +++++++++++++++++++++ test/util.ts | 41 ++++++++++++++++++++++ 4 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 src/cache.ts create mode 100644 test/cache/cache.spec.ts create mode 100644 test/util.ts diff --git a/rollup.config.js b/rollup.config.js index a2baa64..2c344b7 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -14,5 +14,5 @@ export default { } ], plugins: [typescript()], - external: [...Object.keys(pkg.dependencies), "fs", "path", "child_process"] + external: [...Object.keys(pkg.dependencies), "crypto", "fs", "path", "child_process"] } \ No newline at end of file diff --git a/src/cache.ts b/src/cache.ts new file mode 100644 index 0000000..b4a41fc --- /dev/null +++ b/src/cache.ts @@ -0,0 +1,76 @@ +import crypto from 'crypto' +import fs from 'fs' +import path from 'path' +import { log } from './util' + +export class Cache { + private cacheDir: string + + constructor(cacheDir: string) { + this.cacheDir = cacheDir + if (!fs.existsSync(cacheDir)) { + fs.mkdirSync(cacheDir, { recursive: true }) + } + } + + get(namespace: string, id: string): object | undefined { + const fPath = this.fPath(namespace, id) + /** Read file. */ + if (!fs.existsSync(fPath)) { + log.debug(`Failed to get cache "${id}" of namespace "${namespace}".`) + return undefined + } + const data = fs.readFileSync(fPath, { encoding: 'utf-8' }) + /** Parse file. */ + try { + const obj = JSON.parse(data) + return obj + } catch (error) { + log.debug(`Cache object "${id}" of namespace "${namespace}" is corrupted.`) + log.debug(error) + return undefined + } + } + + set(namespace: string, id: string, obj: object) { + const fPath = this.fPath(namespace, id) + fs.writeFileSync(fPath, JSON.stringify(obj, getCircularReplacer())) + } + + shouldUpdate(namespace: string, id: string, lastModifiedTime: number) { + const fPath = this.fPath(namespace, id) + if (fs.existsSync(fPath)) { + const lastModifiedTimeOfCache = fs.statSync(fPath).mtimeMs + return lastModifiedTime > lastModifiedTimeOfCache + } else { + return true + } + } + + fPath(namespace: string, id: string) { + return path.join(this.cacheDir, this._hash(namespace + id)) + } + + private _hash(payload: string) { + return crypto.createHash('sha256').update(payload).digest('hex') + } +} + +/** + * Filter circular object for JSON.stringify() + * @function getCircularReplacer + * @returns {object} Filtered object. + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors/Cyclic_object_value + */ +function getCircularReplacer() { + const seen = new WeakSet() + return (_key, value) => { + if (typeof value === "object" && value !== null) { + if (seen.has(value)) { + return + } + seen.add(value) + } + return value + } +} \ No newline at end of file diff --git a/test/cache/cache.spec.ts b/test/cache/cache.spec.ts new file mode 100644 index 0000000..2a20c2e --- /dev/null +++ b/test/cache/cache.spec.ts @@ -0,0 +1,40 @@ +import fs from 'fs' +import { test } from 'zora' +import { Timer, sleep } from '../util' +import { Cache } from '../../src/cache' + +test('Cache', async (t) => { + const timer = new Timer() + + const cache = new Cache(__dirname) + const namespace = 'test' + const id = '1234' + const data = { title: 'test' } + const fPath = cache.fPath(namespace, id) + + t.equal(typeof cache.get(namespace, id), 'undefined', + 'get() non-existing object should return undefined.') + + cache.set(namespace, id, data) + t.equal(fs.existsSync(fPath), true, + 'set() should write an object to a file.') + + t.deepEqual(cache.get(namespace, id), data, + 'get() existing object should return the object.') + + timer.pause() + + /** Ensure Date.now() can get a larger value. */ + await sleep(500) + + timer.continue() + + t.equal(cache.shouldUpdate(namespace, id, Date.now()), true, + 'shouldUpdate() query with a timestamp larger than the \ +lastModifiedTime of the cache onject should return true.') + + /** Clean up. */ + fs.unlinkSync(fPath) + + timer.stop() +}) \ No newline at end of file diff --git a/test/util.ts b/test/util.ts new file mode 100644 index 0000000..0a163a5 --- /dev/null +++ b/test/util.ts @@ -0,0 +1,41 @@ +export async function sleep(ms: number) { + return new Promise((resolve) => { + setTimeout(resolve, ms) + }) +} + +export function hrDiffToMs(hrDiff: [number, number]) { + return hrDiff[0] * 1e3 + hrDiff[1] / 1e6 +} + +export class Timer { + private start: [number, number] + private elapsedMs: number + private paused: boolean + + /** Start the timer. */ + constructor() { + this.start = process.hrtime() + this.elapsedMs = 0 + this.paused = false + } + + pause() { + const diff = process.hrtime(this.start) + this.elapsedMs += hrDiffToMs(diff) + this.paused = true + } + + continue() { + this.paused = false + this.start = process.hrtime() + } + + /** Stop the timer and print the elapsed time. */ + stop() { + if (!this.paused) { + this.pause() + } + console.log(`Execution time: ${this.elapsedMs} ms`) + } +} \ No newline at end of file From f091e77c5226c0fb61697ef98ce3a6abcff9fd4b Mon Sep 17 00:00:00 2001 From: dragonman225 Date: Mon, 16 Mar 2020 15:57:08 +0800 Subject: [PATCH 07/15] refactor: Add NProperty and NCell abstraction Extract duplicate parts of NXxxProperty, NXxxCell to NProperty and NCell. --- src/ntable.ts | 193 +++++++++++++++++++++++++------------------- test/ntable.spec.ts | 2 +- 2 files changed, 110 insertions(+), 85 deletions(-) diff --git a/src/ntable.ts b/src/ntable.ts index 43b9090..c5ac3e3 100644 --- a/src/ntable.ts +++ b/src/ntable.ts @@ -1,91 +1,96 @@ -import { getPageIDFromPageURL } from "./notion-utils" +import { getPageIDFromPageURL } from './notion-utils' -interface IProperty { +type SelectOption = { + id: string + color: Notion.Collection.ColumnPropertyOptionColor + value: string +} + +interface Property { id: string type: string - records: Map + records: Map } -interface IRecord { +interface Record { id: string - properties: Map + properties: Map } -interface ICell { - property: IProperty - record: IRecord - value: any +interface Cell { + property: Property + record: Record } -interface ITable { +interface Table { id: string - schema: IProperty[] - records: IRecord[] + schema: Property[] + records: Record[] } -class NTextProperty implements IProperty { +class NProperty implements Property { id: string - type: "text" - records: Map + type: string + records: Map - /** Extended properties. */ name: string constructor(id: string, rawProperty: Notion.Collection.ColumnProperty) { this.id = id this.name = rawProperty.name - this.type = "text" + this.type = '' this.records = new Map() } } -class NCheckboxProperty implements IProperty { - id: string - type: "checkbox" - records: Map - - /** Extended properties. */ - name: string +class NTextProperty extends NProperty { + type: 'text' constructor(id: string, rawProperty: Notion.Collection.ColumnProperty) { - this.id = id - this.name = rawProperty.name - this.type = "checkbox" - this.records = new Map() + super(id, rawProperty) + this.type = 'text' } } -type SelectOption = { - id: string - color: Notion.Collection.ColumnPropertyOptionColor - value: string +class NCheckboxProperty extends NProperty { + type: 'checkbox' + + constructor(id: string, rawProperty: Notion.Collection.ColumnProperty) { + super(id, rawProperty) + this.type = 'checkbox' + } } -class NSelectProperty implements IProperty { - id: string - type: "select" - records: Map +class NSelectProperty extends NProperty { + type: 'select' + options: SelectOption[] - /** Extended properties. */ - name: string + constructor(id: string, rawProperty: Notion.Collection.ColumnProperty) { + super(id, rawProperty) + this.type = 'select' + this.options = rawProperty.options || [] + } +} + +class NMultiSelectProperty extends NProperty { + type: 'multi_select' options: SelectOption[] constructor(id: string, rawProperty: Notion.Collection.ColumnProperty) { - this.id = id - this.name = rawProperty.name - this.type = "select" - this.records = new Map() + super(id, rawProperty) + this.type = 'multi_select' this.options = rawProperty.options || [] } } -type NProperty = NTextProperty | NCheckboxProperty | NSelectProperty +type NPropertyUnion = + NTextProperty | NCheckboxProperty | NSelectProperty | + NMultiSelectProperty -class NPageRecord implements IRecord { +class NRecord implements Record { id: string properties: Map - /** Extended properties. */ uri: NAST.URI title: NAST.SemanticString[] icon?: NAST.Emoji | NAST.PublicUrl @@ -106,53 +111,69 @@ class NPageRecord implements IRecord { } } -class NTextCell implements ICell { - property: NTextProperty - record: NPageRecord - value: NAST.SemanticString[] +class NCell implements Cell { + property: NProperty + record: NRecord - constructor(property: NTextProperty, record: NPageRecord, - rawValue: NAST.SemanticString[]) { + constructor(property: NProperty, record: NRecord) { this.property = property this.record = record + } +} + +class NTextCell extends NCell { + value: NAST.SemanticString[] + + constructor(property: NTextProperty, record: NRecord, + rawValue: NAST.SemanticString[]) { + super(property, record) this.value = rawValue } } -class NCheckboxCell implements ICell { - property: NCheckboxProperty - record: NPageRecord +class NCheckboxCell extends NCell { value: boolean - constructor(property: NCheckboxProperty, record: NPageRecord, + constructor(property: NCheckboxProperty, record: NRecord, rawValue: NAST.SemanticString[]) { - this.property = property - this.record = record - this.value = rawValue ? rawValue[0][0] === "Yes" : false + super(property, record) + this.value = rawValue ? rawValue[0][0] === 'Yes' : false } } -class NSelectCell implements ICell { - property: NSelectProperty - record: NPageRecord +class NSelectCell extends NCell { value: SelectOption | undefined - constructor(property: NSelectProperty, record: NPageRecord, + constructor(property: NSelectProperty, record: NRecord, rawValue: NAST.SemanticString[]) { - this.property = property - this.record = record - - const optionNames = rawValue ? rawValue[0][0].split(",") : [] + super(property, record) + const optionNames = rawValue ? rawValue[0][0].split(',') : [] this.value = property.options.find(o => o.value === optionNames[0]) } } -type NCell = NTextCell | NCheckboxCell | NSelectCell +class NMultiSelectCell extends NCell { + value: SelectOption[] -export class NTable implements ITable { + constructor(property: NMultiSelectProperty, record: NRecord, + rawValue: NAST.SemanticString[]) { + super(property, record) + const optionNames = rawValue ? rawValue[0][0].split(',') : [] + this.value = optionNames.reduce((result, optionName) => { + const option = property.options.find(o => o.value === optionName) + if (option) result.push(option) + return result + }, [] as SelectOption[]) + } +} + +type NCellUnion = + NTextCell | NCheckboxCell | NSelectCell | NMultiSelectCell + +export class NTable implements Table { id: string - schema: NProperty[] - records: NPageRecord[] + schema: NPropertyUnion[] + records: NRecord[] constructor(rawTable: NAST.Collection) { this.id = getPageIDFromPageURL(rawTable.uri) @@ -180,7 +201,7 @@ export class NTable implements ITable { this.records = [] rawTable.children.forEach(rawPage => { - const record = new NPageRecord(rawPage) + const record = new NRecord(rawPage) this.records.push(record) this.schema.forEach(property => { const rawPropertyValue = (rawPage.properties || {})[property.id] @@ -193,20 +214,20 @@ export class NTable implements ITable { /** Print the table structure so you can see what it looks like. */ peekStructure() { - let head = "" + let head = '' for (let i = 0; i < this.schema.length; i++) { - head += this.schema[i].constructor.name + " " + head += this.schema[i].constructor.name + ' ' } console.log(head) - console.log("".padEnd(head.length, "-")) + console.log(''.padEnd(head.length, '-')) for (let i = 0; i < this.records.length; i++) { const record = this.records[i] - let row = "" + let row = '' record.properties.forEach((cell, property) => { row += cell.constructor.name - .padEnd(property.constructor.name.length) + " " + .padEnd(property.constructor.name.length) + ' ' }) - row += "-> " + record.constructor.name + row += '-> ' + record.constructor.name console.log(row) } } @@ -217,25 +238,29 @@ function createNProperty( rawProperty: Notion.Collection.ColumnProperty ) { switch (rawProperty.type) { - case "checkbox": + case 'checkbox': return new NCheckboxProperty(propertyId, rawProperty) - case "select": + case 'select': return new NSelectProperty(propertyId, rawProperty) + case 'multi_select': + return new NMultiSelectProperty(propertyId, rawProperty) default: return new NTextProperty(propertyId, rawProperty) } } function createNCell( - property: NProperty, - record: NPageRecord, + property: NPropertyUnion, + record: NRecord, rawValue: NAST.SemanticString[] -) { +): NCellUnion { switch (property.type) { - case "checkbox": + case 'checkbox': return new NCheckboxCell(property, record, rawValue) - case "select": + case 'select': return new NSelectCell(property, record, rawValue) + case 'multi_select': + return new NMultiSelectCell(property, record, rawValue) default: return new NTextCell(property, record, rawValue) } diff --git a/test/ntable.spec.ts b/test/ntable.spec.ts index 203ba96..5098b32 100644 --- a/test/ntable.spec.ts +++ b/test/ntable.spec.ts @@ -21,7 +21,7 @@ async function main() { console.dir(table.recordsGroupByProperty("title"), { depth: 2 }) console.dir(table.recordsGroupByProperty("publish"), { depth: 2 }) console.dir(table.recordsGroupByProperty("template"), { depth: 2 }) - console.dir(table2.id, { depth: 2 }) + console.dir(table2.schema, { depth: 2 }) console.dir(table2.records[0].properties.get(table2.schema[0]), { depth: 2 }) table2.peekStructure() } catch (error) { From 8338f61a5c38781b71b73d7cdd17672e9b04b30d Mon Sep 17 00:00:00 2001 From: dragonman225 Date: Mon, 16 Mar 2020 16:57:12 +0800 Subject: [PATCH 08/15] conf: Adjust ESLint rules --- .eslintrc.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.eslintrc.json b/.eslintrc.json index ac7f1be..3cb4894 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -45,13 +45,14 @@ ], "quotes": [ "error", - "double" + "single" ], "semi": [ "error", "never" ], "no-console": "off", + "@typescript-eslint/explicit-function-return-type": "off", "@typescript-eslint/no-use-before-define": [ "error", { From 864c889efc183245ef631fb3de7f5afcf60f5876 Mon Sep 17 00:00:00 2001 From: dragonman225 Date: Mon, 16 Mar 2020 18:11:51 +0800 Subject: [PATCH 09/15] feat: Add DateTime data type to NTable Refer to NDateTimeProperty and NDateTimeCell. By the way there is a new function arrayAccessMayFail() to help access data in a multi-layered array. --- src/ntable.ts | 57 ++++++++++++++++++++++++++++++++++++++++++--- test/ntable.spec.ts | 4 +++- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/src/ntable.ts b/src/ntable.ts index c5ac3e3..49cba91 100644 --- a/src/ntable.ts +++ b/src/ntable.ts @@ -83,13 +83,22 @@ class NMultiSelectProperty extends NProperty { } } +class NDateTimeProperty extends NProperty { + type: 'date' + + constructor(id: string, rawProperty: Notion.Collection.ColumnProperty) { + super(id, rawProperty) + this.type = 'date' + } +} + type NPropertyUnion = NTextProperty | NCheckboxProperty | NSelectProperty | - NMultiSelectProperty + NMultiSelectProperty | NDateTimeProperty class NRecord implements Record { id: string - properties: Map + properties: Map uri: NAST.URI title: NAST.SemanticString[] @@ -167,8 +176,30 @@ class NMultiSelectCell extends NCell { } } +class NDateTimeCell extends NCell { + value: NAST.DateTime | undefined + + constructor(property: NDateTimeProperty, record: NRecord, + rawValue: NAST.SemanticString[]) { + super(property, record) + try { + /** + * rawValue + * [0]: SemanticString + * [0][1]: FormattingAll[] + * [0][1][0]: FormattingMentionDate + * [0][1][0][1]: DateTime + */ + this.value = arrayAccessMayFail(rawValue)(0)(1)(0)(1)() + } catch (error) { + this.value = undefined + } + } +} + type NCellUnion = - NTextCell | NCheckboxCell | NSelectCell | NMultiSelectCell + NTextCell | NCheckboxCell | NSelectCell | NMultiSelectCell | + NDateTimeCell export class NTable implements Table { id: string @@ -244,6 +275,8 @@ function createNProperty( return new NSelectProperty(propertyId, rawProperty) case 'multi_select': return new NMultiSelectProperty(propertyId, rawProperty) + case 'date': + return new NDateTimeProperty(propertyId, rawProperty) default: return new NTextProperty(propertyId, rawProperty) } @@ -261,7 +294,25 @@ function createNCell( return new NSelectCell(property, record, rawValue) case 'multi_select': return new NMultiSelectCell(property, record, rawValue) + case 'date': + return new NDateTimeCell(property, record, rawValue) default: return new NTextCell(property, record, rawValue) } +} + +function arrayAccessMayFail(arrayLike) { + return function (i) { + /** Call with no parameter to signal the end of the access chain. */ + if (typeof i === 'undefined') { + return arrayLike + } + /** Access the array. */ + if (Array.isArray(arrayLike)) { + return arrayAccessMayFail(arrayLike[i]) + } else { + /** Throw when you want to access the array but no way to do. */ + throw new Error(arrayLike) + } + } } \ No newline at end of file diff --git a/test/ntable.spec.ts b/test/ntable.spec.ts index 5098b32..f887f35 100644 --- a/test/ntable.spec.ts +++ b/test/ntable.spec.ts @@ -22,7 +22,9 @@ async function main() { console.dir(table.recordsGroupByProperty("publish"), { depth: 2 }) console.dir(table.recordsGroupByProperty("template"), { depth: 2 }) console.dir(table2.schema, { depth: 2 }) - console.dir(table2.records[0].properties.get(table2.schema[0]), { depth: 2 }) + table2.records.forEach(record => { + console.dir((record.properties.get(table2.schema[7]) || {}).value) + }) table2.peekStructure() } catch (error) { console.error(error) From 658e434c0bb9873e25d0a0640b45f515db9c5333 Mon Sep 17 00:00:00 2001 From: Alexander Wang Date: Tue, 17 Mar 2020 01:54:16 +0800 Subject: [PATCH 10/15] feat: Simplify the usage of arrayAccessMayFail() (now objAccess()) Also, I do some cleanup in src/util.ts --- src/ntable.ts | 37 ++++------------ src/util.ts | 105 ++++++++++++++++++++++++++++++++------------ test/ntable.spec.ts | 2 +- 3 files changed, 86 insertions(+), 58 deletions(-) diff --git a/src/ntable.ts b/src/ntable.ts index 49cba91..6411806 100644 --- a/src/ntable.ts +++ b/src/ntable.ts @@ -1,4 +1,5 @@ import { getPageIDFromPageURL } from './notion-utils' +import { objAccess } from './util' type SelectOption = { id: string @@ -182,18 +183,14 @@ class NDateTimeCell extends NCell { constructor(property: NDateTimeProperty, record: NRecord, rawValue: NAST.SemanticString[]) { super(property, record) - try { - /** - * rawValue - * [0]: SemanticString - * [0][1]: FormattingAll[] - * [0][1][0]: FormattingMentionDate - * [0][1][0][1]: DateTime - */ - this.value = arrayAccessMayFail(rawValue)(0)(1)(0)(1)() - } catch (error) { - this.value = undefined - } + /** + * rawValue + * [0]: SemanticString + * [0][1]: FormattingAll[] + * [0][1][0]: FormattingMentionDate + * [0][1][0][1]: DateTime + */ + this.value = objAccess(rawValue)(0)(1)(0)(1)() } } @@ -299,20 +296,4 @@ function createNCell( default: return new NTextCell(property, record, rawValue) } -} - -function arrayAccessMayFail(arrayLike) { - return function (i) { - /** Call with no parameter to signal the end of the access chain. */ - if (typeof i === 'undefined') { - return arrayLike - } - /** Access the array. */ - if (Array.isArray(arrayLike)) { - return arrayAccessMayFail(arrayLike[i]) - } else { - /** Throw when you want to access the array but no way to do. */ - throw new Error(arrayLike) - } - } } \ No newline at end of file diff --git a/src/util.ts b/src/util.ts index d9b6caa..1af28f5 100644 --- a/src/util.ts +++ b/src/util.ts @@ -6,44 +6,28 @@ import { Logger } from '@dnpr/logger' * Wrapper of console.log(). */ export const log = new Logger('notablog', { - logLevel: typeof process.env.DEBUG_EN !== 'undefined' ? 'debug' : 'info', + logLevel: typeof process.env.DEBUG !== 'undefined' ? 'debug' : 'info', useColor: typeof process.env.NO_COLOR !== 'undefined' ? false : true }) /** - * Failsafe JSON.parse() wrapper. - * @param {*} str - Payload to parse. - * @returns {Object} Parsed object when success, undefined when fail. + * Log a message to indicate a feature is being deprecated. + * @param msg - The message. */ -export function parseJSON(str) { - try { - return JSON.parse(str) - } catch (error) { - return void 0 - } +export function DEPRECATE(msg: string) { + log.warn(msg) } /** - * @typedef {Object} NotablogConfig - * @property {string} url - URL of a Notion table. - * @property {string} theme - Name of a theme. - * @property {string} previewBrowser - Path to a browser executable. - */ - -/** - * Read and parse the JSON config file. - * @param {string} workDir - A valid Notablog starter directory. - * @returns {NotablogConfig} + * Failsafe JSON.parse() wrapper. + * @param str - Payload to parse. + * @returns Parsed object when success, undefined when fail. */ -export function getConfig(workDir) { - const cPath = path.join(workDir, 'config.json') - const cFile = fs.readFileSync(cPath, { encoding: 'utf-8' }) +export function parseJSON(str): object | undefined { try { - const config = JSON.parse(cFile) - return config + return JSON.parse(str) } catch (error) { - console.error(error) - throw new Error(`Fail to parse config at ${cPath}`) + return void 0 } } @@ -63,10 +47,73 @@ export function outDir(workDir) { export function numToOrder(n: number) { switch (n) { case 1: - return "1st" + return '1st' case 2: - return "2nd" + return '2nd' + case 3: + return '3rd' default: return `${n}th` } +} + +/** + * Make doing multi-layer object or array access like `obj.a.b.c.d` or + * `arr[0][1][0][1]` more easily. + * + * Example Usage: + * + * In the constructor of {@link NDateTimeCell}, we want to access + * a `NAST.DateTime` stored in a `NAST.SemanticString[]`. + * + * With vanilla JS, we would write: + * + * ``` + * something[0][1][0][1] + * ``` + * + * which is prone to get the error: + * + * ``` + * TypeError: Cannot read property '0' of undefined + * ``` + * + * We could use `try...catch...` to wrap it: + * + * ``` + * try { + * result = something[0][1][0][1] + * } catch(error) { + * result = undefined + * } + * ``` + * + * But with this helper function, we could simply write: + * + * ``` + * result = objAccess(something)(0)(1)(0)(1)() + * ``` + * + * However, note that the cost is that an `undefined` occurred in the + * middle of the function call chain would be passed to the end + * instead of stop execution. + * + * @param objLike - An object (or an array). + */ +export function objAccess(objLike) { + return function (key) { + /** Call with no parameter to signal the end of the access chain. */ + if (typeof key === 'undefined') { + return objLike + } + /** + * Try to access the array if it is truthy. + * Otherwise, just pass the falsy value. + */ + if (objLike) { + return objAccess(objLike[key]) + } else { + return objAccess(objLike) + } + } } \ No newline at end of file diff --git a/test/ntable.spec.ts b/test/ntable.spec.ts index f887f35..97977a4 100644 --- a/test/ntable.spec.ts +++ b/test/ntable.spec.ts @@ -23,7 +23,7 @@ async function main() { console.dir(table.recordsGroupByProperty("template"), { depth: 2 }) console.dir(table2.schema, { depth: 2 }) table2.records.forEach(record => { - console.dir((record.properties.get(table2.schema[7]) || {}).value) + console.dir((record.properties.get(table2.schema[8]) || {}).value) }) table2.peekStructure() } catch (error) { From 79a41a38c9db4afd7d0dea2d76bde3adea6b3489 Mon Sep 17 00:00:00 2001 From: Alexander Wang Date: Tue, 17 Mar 2020 01:56:10 +0800 Subject: [PATCH 11/15] chore: Write tests for objAccess() and numToOrder() --- test/util.spec.ts | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 test/util.spec.ts diff --git a/test/util.spec.ts b/test/util.spec.ts new file mode 100644 index 0000000..a1c4620 --- /dev/null +++ b/test/util.spec.ts @@ -0,0 +1,36 @@ +import { test } from 'zora' +import { objAccess, numToOrder } from '../src/util' + +test('objAccess', (t) => { + + const arr = [[[[1234]]]] + const obj = { + a: { + b: { + c: { + d: 'hello' + } + } + } + } + + t.equal(objAccess(arr)(0)(0)(0)(0)(), 1234, + 'Array: access existing value returns the value.') + t.equal(objAccess(arr)(0)(0)(1)(1)(), undefined, + 'Array: access non-existing value returns undefined.') + t.equal(objAccess(obj)('a')('b')('c')('d')(), 'hello', + 'Object: access existing value returns the value.') + t.equal(objAccess(obj)('a')('b')('e')('c')(), undefined, + 'Object: access non-existing value returns undefined.') + +}) + +test('numToOrder', (t) => { + + t.equal(numToOrder(1), '1st', 'Convert 1 to 1st.') + t.equal(numToOrder(2), '2nd', 'Convert 2 to 2nd.') + t.equal(numToOrder(3), '3rd', 'Convert 3 to 3rd.') + t.equal(numToOrder(4), '4th', 'Convert others (4) to nth (4th).') + t.equal(numToOrder(10), '10th', 'Convert others (10) to nth (10th).') + +}) \ No newline at end of file From 3f04e5c081d80bd68d6c9f8d65e0ccca1bd9ffa2 Mon Sep 17 00:00:00 2001 From: Alexander Wang Date: Sun, 29 Mar 2020 23:55:24 +0800 Subject: [PATCH 12/15] doc: Update README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2379ec9..f779240 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,6 @@ Generated by `dependency-cruiser` NPM package. ![](assets/deps_graph.svg) -### EJS +### Project Status -There is an experimental version at `ejs` branch that uses [EJS](https://ejs.co/) as template engine. Main advantage of EJS is its `include` feature, which enable us to make repetitive parts of template into components that can be reused. I also made an EJS version of `notablog-theme-pure` [here](https://github.com/dragonman225/notablog-theme-pure-ejs). +See https://dragonman225.js.org/notablog-stat.html From faf582d7aa7cc5c3294270428e9a7d138a95e9a6 Mon Sep 17 00:00:00 2001 From: Alexander Wang Date: Sun, 26 Apr 2020 17:17:17 +0800 Subject: [PATCH 13/15] doc: Update README Fix some confusing sentences. --- README.md | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index f779240..ccd9c87 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ -# notablog +# Notablog ![version](https://img.shields.io/npm/v/notablog.svg?style=flat-square&color=007acc&label=version) ![license](https://img.shields.io/github/license/dragonman225/notablog.svg?style=flat-square&label=license&color=08CE5D) Generate a minimalistic blog from a Notion.so table. -Here are some images of [my blog](https://dragonman225.js.org/), using [`notablog-starter`'s default theme](https://github.com/dragonman225/notablog-starter/tree/master/themes/pure). 🙂 +Below are some screenshots of [my blog](https://dragonman225.js.org/). 🙂 | Mobile | Desktop | | :---------------------------: | :----------------------------: | @@ -55,22 +55,28 @@ Here are some images of [my blog](https://dragonman225.js.org/), using [`notablo notablog generate . ``` -7. After it finishes, go to `notablog-starter/public/` directory, open `index.html` with a browser to preview your site. Or, you can change `previewBrowser` field in `config.json` to the path of a browser you use and run command: +7. After it finishes, go to `notablog-starter/public/` directory, open `index.html` with a browser to preview your site. + +* Optionally, you could change the `previewBrowser` field in `config.json` to the path of a browser executable you have on your computer and issue the following command anywhere to preview. ```bash - notablog preview . + notablog preview ``` -### Your site is ready now! +### Congratulations! Your website is ready now! + +* You can copy files in `notablog-starter/public/` directory to a server or upload them to any static hosting service to share your content with the world. -* You can copy files in `notablog-starter/public/` directory to your server or upload them to any static hosting service to share your content with the world. * Whenever you want to update your site, go into `notablog-starter/` directory and issue command `notablog generate .`, or issue the command from outside `notablog-starter/` with the pattern `notablog generate `. * Some options for static hosting services: + * [Github Pages](https://pages.github.com/) * [Netlify](https://www.netlify.com/) * [surge.sh](https://surge.sh) + * Some options for self-hosting: + * [nginx](https://www.nginx.com/) * [lighttpd](https://www.lighttpd.net/) * [Apache httpd](https://httpd.apache.org/) From 0bee7a97d24941cb29d0dafdc5f80a4235e76f65 Mon Sep 17 00:00:00 2001 From: Alexander Wang Date: Sun, 26 Apr 2020 17:40:13 +0800 Subject: [PATCH 14/15] doc: Update README --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index ccd9c87..cb2b9d5 100644 --- a/README.md +++ b/README.md @@ -220,3 +220,7 @@ Generated by `dependency-cruiser` NPM package. ### Project Status See https://dragonman225.js.org/notablog-stat.html + +### Git + +`master` is the working branch, latest release is `v0.4.1`, which is at branch `v0.4.1`. \ No newline at end of file From e8679bd7d368c68f6ebce8ca956be89f15057810 Mon Sep 17 00:00:00 2001 From: Kevin Chen Date: Wed, 6 May 2020 00:35:59 +0800 Subject: [PATCH 15/15] escape tag string --- src/render-index.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/render-index.ts b/src/render-index.ts index 5fcaf9c..b52d8dc 100644 --- a/src/render-index.ts +++ b/src/render-index.ts @@ -4,6 +4,10 @@ import Sqrl from 'squirrelly' import { log } from './util' +function escapeTag(tag: string) { + return tag.replace(/[&\/\\#, +()$~%.'":*?<>{}]/g, '-'); +} + export function renderIndex(task) { const siteMeta = task.data.siteMeta const templateProvider = task.tools.templateProvider @@ -13,6 +17,7 @@ export function renderIndex(task) { const indexPath = path.join(outDir, 'index.html') Sqrl.autoEscaping(false) + Sqrl.defineFilter('escapeTag', (str: string) => escapeTag(str)) log.info('Render home page') const html = Sqrl.Render(templateProvider.get('index'), { @@ -27,6 +32,6 @@ export function renderIndex(task) { tagName: tagVal, pages: pageMetas }) - fs.writeFileSync(`${config.tagDir}/${tagVal}.html`, html, { encoding: 'utf-8' }) + fs.writeFileSync(`${config.tagDir}/${escapeTag(tagVal)}.html`, html, { encoding: 'utf-8' }) }) } \ No newline at end of file