From 6520a481ca7005f52b9b8544d3b822b3b57d6b5f Mon Sep 17 00:00:00 2001 From: Laurent Cozic Date: Tue, 31 Aug 2021 13:46:46 +0100 Subject: [PATCH] Server: Added Help page for Joplin Cloud --- .eslintignore | 3 + .gitignore | 3 + packages/renderer/headerAnchor.ts | 92 +++++++++++ packages/renderer/index.ts | 2 + packages/server/public/css/main.css | 20 +++ packages/server/src/config.ts | 2 +- packages/server/src/routes/index/help.ts | 21 +++ packages/server/src/routes/routes.ts | 2 + .../server/src/services/MustacheService.ts | 70 ++++++-- packages/server/src/views/index/help.md | 20 +++ .../server/src/views/partials/navbar.mustache | 3 + packages/tools/package.json | 1 + packages/tools/website/utils/render.ts | 151 +++++++++--------- 13 files changed, 305 insertions(+), 85 deletions(-) create mode 100644 packages/renderer/headerAnchor.ts create mode 100644 packages/server/src/routes/index/help.ts create mode 100644 packages/server/src/views/index/help.md diff --git a/.eslintignore b/.eslintignore index 11deefda4..73a6f0f3b 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1725,6 +1725,9 @@ packages/renderer/MdToHtml/setupLinkify.js.map packages/renderer/MdToHtml/validateLinks.d.ts packages/renderer/MdToHtml/validateLinks.js packages/renderer/MdToHtml/validateLinks.js.map +packages/renderer/headerAnchor.d.ts +packages/renderer/headerAnchor.js +packages/renderer/headerAnchor.js.map packages/renderer/htmlUtils.d.ts packages/renderer/htmlUtils.js packages/renderer/htmlUtils.js.map diff --git a/.gitignore b/.gitignore index 4e63199df..ff2c2ca7b 100644 --- a/.gitignore +++ b/.gitignore @@ -1710,6 +1710,9 @@ packages/renderer/MdToHtml/setupLinkify.js.map packages/renderer/MdToHtml/validateLinks.d.ts packages/renderer/MdToHtml/validateLinks.js packages/renderer/MdToHtml/validateLinks.js.map +packages/renderer/headerAnchor.d.ts +packages/renderer/headerAnchor.js +packages/renderer/headerAnchor.js.map packages/renderer/htmlUtils.d.ts packages/renderer/htmlUtils.js packages/renderer/htmlUtils.js.map diff --git a/packages/renderer/headerAnchor.ts b/packages/renderer/headerAnchor.ts new file mode 100644 index 000000000..0a0282f34 --- /dev/null +++ b/packages/renderer/headerAnchor.ts @@ -0,0 +1,92 @@ +export default function(markdownIt: any) { + markdownIt.core.ruler.push('anchorHeader', (state: any): boolean => { + const tokens = state.tokens; + const Token = state.Token; + const doneNames = []; + + const headingTextToAnchorName = (text: string, doneNames: string[]) => { + const allowed = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + let lastWasDash = true; + let output = ''; + for (let i = 0; i < text.length; i++) { + const c = text[i]; + if (allowed.indexOf(c) < 0) { + if (lastWasDash) continue; + lastWasDash = true; + output += '-'; + } else { + lastWasDash = false; + output += c; + } + } + + output = output.toLowerCase(); + + while (output.length && output[output.length - 1] === '-') { + output = output.substr(0, output.length - 1); + } + + let temp = output; + let index = 1; + while (doneNames.indexOf(temp) >= 0) { + temp = `${output}-${index}`; + index++; + } + output = temp; + + return output; + }; + + const createAnchorTokens = (anchorName: string) => { + const output = []; + + { + const token = new Token('heading_anchor_open', 'a', 1); + token.attrs = [ + ['name', anchorName], + ['href', `#${anchorName}`], + ['class', 'heading-anchor'], + ]; + output.push(token); + } + + { + const token = new Token('text', '', 0); + token.content = '🔗'; + output.push(token); + } + + { + const token = new Token('heading_anchor_close', 'a', -1); + output.push(token); + } + + return output; + }; + + let insideHeading = false; + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + + if (token.type === 'heading_open') { + insideHeading = true; + continue; + } + + if (token.type === 'heading_close') { + insideHeading = false; + continue; + } + + if (insideHeading && token.type === 'inline') { + const anchorName = headingTextToAnchorName(token.content, doneNames); + doneNames.push(anchorName); + const anchorTokens = createAnchorTokens(anchorName); + // token.children = anchorTokens.concat(token.children); + token.children = token.children.concat(anchorTokens); + } + } + + return true; + }); +} diff --git a/packages/renderer/index.ts b/packages/renderer/index.ts index e2226f18d..f311b6742 100644 --- a/packages/renderer/index.ts +++ b/packages/renderer/index.ts @@ -4,6 +4,7 @@ import HtmlToHtml from './HtmlToHtml'; import utils from './utils'; import setupLinkify from './MdToHtml/setupLinkify'; import validateLinks from './MdToHtml/validateLinks'; +import headerAnchor from './headerAnchor'; const assetsToHeaders = require('./assetsToHeaders'); export { @@ -13,6 +14,7 @@ export { HtmlToHtml, setupLinkify, validateLinks, + headerAnchor, assetsToHeaders, utils, }; diff --git a/packages/server/public/css/main.css b/packages/server/public/css/main.css index 0dd7ae8c1..99a195af3 100644 --- a/packages/server/public/css/main.css +++ b/packages/server/public/css/main.css @@ -63,4 +63,24 @@ ul.pagination-list li { .readable-block { max-width: 740px; +} + +a.heading-anchor { + display: inline-block; + opacity: 0; + width: 1.3em; + font-size: 0.7em; + margin-left: 0.4em; + line-height: 1em; + text-decoration: none; + transition: opacity 0.3s; +} +a.heading-anchor:hover, +h1:hover a.heading-anchor, +h2:hover a.heading-anchor, +h3:hover a.heading-anchor, +h4:hover a.heading-anchor, +h5:hover a.heading-anchor, +h6:hover a.heading-anchor { + opacity: 1; } \ No newline at end of file diff --git a/packages/server/src/config.ts b/packages/server/src/config.ts index 83829fba8..a161619a8 100644 --- a/packages/server/src/config.ts +++ b/packages/server/src/config.ts @@ -149,7 +149,7 @@ export async function initConfig(envType: Env, env: EnvVariables, overrides: any config_ = { appVersion: packageJson.version, appName, - isJoplinCloud: apiBaseUrl.includes('.joplincloud.com'), + isJoplinCloud: apiBaseUrl.includes('.joplincloud.com') || apiBaseUrl.includes('.joplincloud.local'), env: envType, rootDir: rootDir, viewDir: viewDir, diff --git a/packages/server/src/routes/index/help.ts b/packages/server/src/routes/index/help.ts new file mode 100644 index 000000000..3f858bbd4 --- /dev/null +++ b/packages/server/src/routes/index/help.ts @@ -0,0 +1,21 @@ +import { SubPath } from '../../utils/routeUtils'; +import Router from '../../utils/Router'; +import { RouteType } from '../../utils/types'; +import { AppContext } from '../../utils/types'; +import { ErrorMethodNotAllowed } from '../../utils/errors'; +import defaultView from '../../utils/defaultView'; + +const router: Router = new Router(RouteType.Web); + +router.public = true; + +router.get('help', async (_path: SubPath, ctx: AppContext) => { + if (ctx.method === 'GET') { + const view = defaultView('help', 'Help'); + return view; + } + + throw new ErrorMethodNotAllowed(); +}); + +export default router; diff --git a/packages/server/src/routes/routes.ts b/packages/server/src/routes/routes.ts index 01f25d0a9..745f60f60 100644 --- a/packages/server/src/routes/routes.ts +++ b/packages/server/src/routes/routes.ts @@ -25,6 +25,7 @@ import indexStripe from './index/stripe'; import indexTerms from './index/terms'; import indexPrivacy from './index/privacy'; import indexUpgrade from './index/upgrade'; +import indexHelp from './index/help'; import defaultRoute from './default'; @@ -54,6 +55,7 @@ const routes: Routers = { 'terms': indexTerms, 'privacy': indexPrivacy, 'upgrade': indexUpgrade, + 'help': indexHelp, '': defaultRoute, }; diff --git a/packages/server/src/services/MustacheService.ts b/packages/server/src/services/MustacheService.ts index cee5e7b85..12b7cddda 100644 --- a/packages/server/src/services/MustacheService.ts +++ b/packages/server/src/services/MustacheService.ts @@ -1,10 +1,13 @@ import * as Mustache from 'mustache'; import * as fs from 'fs-extra'; +import { extname } from 'path'; import config from '../config'; import { filename } from '@joplin/lib/path-utils'; import { NotificationView } from '../utils/types'; import { User } from '../services/database/types'; import { makeUrl, UrlType } from '../utils/routeUtils'; +import MarkdownIt = require('markdown-it'); +import { headerAnchor } from '@joplin/renderer'; export interface RenderOptions { partials?: any; @@ -37,6 +40,7 @@ interface GlobalParams { showErrorStackTraces?: boolean; userDisplayName?: string; supportEmail?: string; + isJoplinCloud?: boolean; } export function isView(o: any): boolean { @@ -50,6 +54,7 @@ export default class MustacheService { private baseAssetUrl_: string; private prefersDarkEnabled_: boolean = true; private partials_: Record = {}; + private fileContentCache_: Record = {}; public constructor(viewDir: string, baseAssetUrl: string) { this.viewDir_ = viewDir; @@ -92,11 +97,35 @@ export default class MustacheService { termsUrl: config().termsEnabled ? makeUrl(UrlType.Terms) : '', privacyUrl: config().termsEnabled ? makeUrl(UrlType.Privacy) : '', showErrorStackTraces: config().showErrorStackTraces, + isJoplinCloud: config().isJoplinCloud, }; } + private async viewFilePath(name: string): Promise { + const pathsToTry = [ + `${this.viewDir_}/${name}.mustache`, + `${this.viewDir_}/${name}.md`, + ]; + + for (const p of pathsToTry) { + if (await fs.pathExists(p)) return p; + } + + throw new Error(`Cannot find view file: ${name}`); + } + private async loadTemplateContent(path: string): Promise { - return fs.readFile(path, 'utf8'); + if (this.fileContentCache_[path]) return this.fileContentCache_[path]; + + try { + const output = await fs.readFile(path, 'utf8'); + this.fileContentCache_[path] = output; + return output; + } catch (error) { + // Shouldn't have to do this but node.fs error messages are useless + // so throw a new error to get a proper stack trace. + throw new Error(`Cannot load view ${path}: ${error.message}`); + } } private resolvesFilePaths(type: string, paths: string[]): string[] { @@ -114,10 +143,38 @@ export default class MustacheService { return ''; } + private async renderFileContent(filePath: string, view: View, globalParams: GlobalParams = null): Promise { + const ext = extname(filePath); + + if (ext === '.mustache') { + return Mustache.render( + await this.loadTemplateContent(filePath), + { + ...view.content, + global: globalParams, + }, + this.partials_ + ); + } else if (ext === '.md') { + const markdownIt = new MarkdownIt({ + linkify: true, + }); + + markdownIt.use(headerAnchor); + + // Need to wrap in a `content` element so that default styles are + // applied to it. + // https://github.com/jgthms/bulma/issues/3232#issuecomment-909176563 + return `
${markdownIt.render(await this.loadTemplateContent(filePath))}
`; + } + + throw new Error(`Unsupported view extension: ${ext}`); + } + public async renderView(view: View, globalParams: GlobalParams = null): Promise { const cssFiles = this.resolvesFilePaths('css', view.cssFiles || []); const jsFiles = this.resolvesFilePaths('js', view.jsFiles || []); - const filePath = `${this.viewDir_}/${view.path}.mustache`; + const filePath = await this.viewFilePath(view.path); globalParams = { ...this.defaultLayoutOptions, @@ -125,14 +182,7 @@ export default class MustacheService { userDisplayName: this.userDisplayName(globalParams ? globalParams.owner : null), }; - const contentHtml = Mustache.render( - await this.loadTemplateContent(filePath), - { - ...view.content, - global: globalParams, - }, - this.partials_ - ); + const contentHtml = await this.renderFileContent(filePath, view, globalParams); const layoutView: any = { global: globalParams, diff --git a/packages/server/src/views/index/help.md b/packages/server/src/views/index/help.md new file mode 100644 index 000000000..eb4082014 --- /dev/null +++ b/packages/server/src/views/index/help.md @@ -0,0 +1,20 @@ +# Joplin Cloud Help + +## How can I change my details? + +Most of your details can be found in your Profile page. To open it, click on the Profile button - this is the button in the top right corner, with your name or email on it. + +## How can I cancel my account? + +Click on the [Profile button](#how-can-i-change-my-details), then scroll down and click on "Cancel subscription". + +## How can I get more space? + +If you are on a Basic account, you may upgrade to a Pro account to get more space. Click on the [Profile button](#how-can-i-change-my-details), then scroll down and select "Upgrade account". + +If you are already on a Pro account, and you need more space for specific reasons, please contact us as we may increase the cap in some cases. + +## Further information + +- [Joplin Cloud Privacy Policy](/privacy) +- [Joplin Cloud Terms & Conditions](/terms) diff --git a/packages/server/src/views/partials/navbar.mustache b/packages/server/src/views/partials/navbar.mustache index e72daaff3..39bbdf406 100644 --- a/packages/server/src/views/partials/navbar.mustache +++ b/packages/server/src/views/partials/navbar.mustache @@ -18,6 +18,9 @@ Log