From 17a63eeb4719528575d729b7a0539054ed00f96f Mon Sep 17 00:00:00 2001 From: Henry Heino Date: Fri, 19 Jan 2024 11:09:51 -0800 Subject: [PATCH] Migrate to CodeMirror 6 --- package.json | 33 +++-- src/QuickLinksPlugin.ts | 308 +++++++++++++++++++++++++++------------- src/show-hint.css | 14 +- webpack.config.js | 193 ++++++++++++++++++++----- 4 files changed, 392 insertions(+), 156 deletions(-) diff --git a/package.json b/package.json index 3e43d91..1924b4f 100644 --- a/package.json +++ b/package.json @@ -2,27 +2,32 @@ "name": "joplin-plugin-quick-links", "version": "1.2.4", "scripts": { - "dist": "webpack --joplin-plugin-config buildMain && webpack --joplin-plugin-config buildExtraScripts && webpack --joplin-plugin-config createArchive", + "dist": "webpack --env joplin-plugin-config=buildMain && webpack --env joplin-plugin-config=buildExtraScripts && webpack --env joplin-plugin-config=createArchive", "prepare": "npm run dist", - "update": "npm install -g generator-joplin && yo joplin --update" + "updateVersion": "webpack --env joplin-plugin-config=updateVersion", + "update": "npm install -g generator-joplin && yo joplin --node-package-manager npm --update --force" }, "license": "MIT", "keywords": [ "joplin-plugin" ], + "files": [ + "publish" + ], "devDependencies": { - "@types/node": "^14.0.14", + "@types/node": "^18.7.13", "@types/codemirror": "^5.60.7", + "@codemirror/autocomplete": "^6.12.0", + "@codemirror/lang-markdown": "^6.2.4", "chalk": "^4.1.0", - "copy-webpack-plugin": "^6.1.0", - "fs-extra": "^9.0.1", - "glob": "^7.1.6", - "on-build-webpack": "^0.1.0", - "tar": "^6.0.5", - "ts-loader": "^7.0.5", - "typescript": "^3.9.3", - "webpack": "^4.43.0", - "webpack-cli": "^3.3.11", - "yargs": "^16.2.0" + "copy-webpack-plugin": "^11.0.0", + "fs-extra": "^10.1.0", + "glob": "^8.0.3", + "tar": "^6.1.11", + "ts-loader": "^9.3.1", + "typescript": "^4.8.2", + "webpack": "^5.74.0", + "webpack-cli": "^4.10.0", + "@joplin/lib": "~2.9" } -} \ No newline at end of file +} diff --git a/src/QuickLinksPlugin.ts b/src/QuickLinksPlugin.ts index 1e4975f..38c8ee5 100644 --- a/src/QuickLinksPlugin.ts +++ b/src/QuickLinksPlugin.ts @@ -1,4 +1,9 @@ import { Editor } from "codemirror"; +import type * as CodeMirrorAutocompleteType from '@codemirror/autocomplete'; +import type * as CodeMirrorMarkdownType from '@codemirror/lang-markdown'; +import type * as CodeMirrorStateType from '@codemirror/state'; +import type { CompletionContext, CompletionResult, Completion } from '@codemirror/autocomplete'; +import type { EditorView } from '@codemirror/view'; interface Hint { text: string; @@ -7,125 +12,228 @@ interface Hint { render?: Function; } -module.exports = { - default: function(context: any) { +interface PluginContext { + postMessage(message: any): Promise; +} + +function codeMirror5Plugin(context: PluginContext, CodeMirror: any) { + function NewNoteHint(prefix: string, todo: boolean) { + let description = "New Note"; + + if(todo) + description = "New Task"; + + const newNoteHint: Hint = { + text: prefix, + hint: async (cm, data, completion) => { + const from = completion.from || data.from; + from.ch -= 2; + + const response = await context.postMessage({command: 'createNote', title: prefix, todo: todo}); + cm.replaceRange(`[${prefix}](:/${response.newNote.id})`, from, cm.getCursor(), "complete"); + }, + }; + + newNoteHint.render = (elem, _data, _completion) => { + const p = elem.ownerDocument.createElement('div'); + p.setAttribute('style', 'width: 100%; display:table;'); + elem.appendChild(p); + p.innerHTML = ` +
${prefix}
+
${description}
+ ` + }; + return newNoteHint; + } - function NewNoteHint(prefix: string, todo: boolean) { - let description = "New Note"; + const buildHints = async (prefix: string) =>{ + const response = await context.postMessage({ command: 'getNotes', prefix: prefix }); - if(todo) - description = "New Task"; + let hints: Hint[] = []; - const newNoteHint: Hint = { - text: prefix, - hint: async (cm, data, completion) => { + const notes = response.notes; + for (let i = 0; i < notes.length; i++) { + const note = notes[i]; + const hint: Hint = { + text: note.title, + hint: async (cm: Editor, data, completion) => { const from = completion.from || data.from; from.ch -= 2; - - const response = await context.postMessage({command: 'createNote', title: prefix, todo: todo}); - cm.replaceRange(`[${prefix}](:/${response.newNote.id})`, from, cm.getCursor(), "complete"); + cm.replaceRange(`[${note.title}](:/${note.id})`, from, cm.getCursor(), "complete"); + if (response.selectText) { + const selectionStart = Object.assign({}, from); + const selectionEnd = Object.assign({}, from); + selectionStart.ch += 1; + selectionEnd.ch += 1 + note.title.length; + cm.setSelection(selectionStart, selectionEnd) + } }, }; + if (response.showFolders) { + const folder = !!note.folder ? note.folder : "unknown"; + hint.render = (elem, _data, _completion) => { + const p = elem.ownerDocument.createElement('div'); + p.setAttribute('style', 'width: 100%; display:table;'); + elem.appendChild(p); + p.innerHTML = ` +
${note.title}
+
In ${folder}
+ ` + }; + } else { + hint.displayText = note.title; + } + hints.push(hint); + } - newNoteHint.render = (elem, _data, _completion) => { - const p = elem.ownerDocument.createElement('div'); - p.setAttribute('style', 'width: 100%; display:table;'); - elem.appendChild(p); - p.innerHTML = ` -
${prefix}
-
${description}
- ` - }; - return newNoteHint; + if(response.allowNewNotes && prefix) { + hints.push(NewNoteHint(prefix, false)); + hints.push(NewNoteHint(prefix, true)); } - const buildHints = async (prefix: string) =>{ - const response = await context.postMessage({ command: 'getNotes', prefix: prefix }); - - let hints: Hint[] = []; - - const notes = response.notes; - for (let i = 0; i < notes.length; i++) { - const note = notes[i]; - const hint: Hint = { - text: note.title, - hint: async (cm: Editor, data, completion) => { - const from = completion.from || data.from; - from.ch -= 2; - cm.replaceRange(`[${note.title}](:/${note.id})`, from, cm.getCursor(), "complete"); - if (response.selectText) { - const selectionStart = Object.assign({}, from); - const selectionEnd = Object.assign({}, from); - selectionStart.ch += 1; - selectionEnd.ch += 1 + note.title.length; - cm.setSelection(selectionStart, selectionEnd) - } - }, + return hints; + } + + CodeMirror.defineOption('quickLinks', false, function(cm, value, prev) { + if (!value) return; + + cm.on('inputRead', async function (cm1, change) { + if (!cm1.state.completionActive && cm.getTokenAt(cm.getCursor()).string === '@@') { + const start = {line: change.from.line, ch: change.from.ch + 1}; + + const hint = function(cm, callback) { + const cursor = cm.getCursor(); + let prefix = cm.getRange(start, cursor) || ''; + + buildHints(prefix).then(hints => { + callback({ + list: hints, + from: {line: change.from.line, ch: change.from.ch + 1}, + to: {line: change.to.line, ch: change.to.ch + 1}, + }); + }); }; - if (response.showFolders) { - const folder = !!note.folder ? note.folder : "unknown"; - hint.render = (elem, _data, _completion) => { - const p = elem.ownerDocument.createElement('div'); - p.setAttribute('style', 'width: 100%; display:table;'); - elem.appendChild(p); - p.innerHTML = ` -
${note.title}
-
In ${folder}
- ` - }; - } else { - hint.displayText = note.title; - } - hints.push(hint); - } - if(response.allowNewNotes && prefix) { - hints.push(NewNoteHint(prefix, false)); - hints.push(NewNoteHint(prefix, true)); + setTimeout(function () { + CodeMirror.showHint(cm, hint, { + completeSingle: false, + closeOnUnfocus: true, + async: true, + closeCharacters: /[()\[\]{};:>,]/ + }); + }, 10); } + }); + }); +} + +function codeMirror6Plugin(pluginContext: PluginContext, CodeMirror: any) { + const { autocompletion, insertCompletionText } = require('@codemirror/autocomplete') as typeof CodeMirrorAutocompleteType; + const { markdownLanguage } = require('@codemirror/lang-markdown') as typeof CodeMirrorMarkdownType; + const { EditorSelection } = require('@codemirror/state') as typeof CodeMirrorStateType; - return hints; + const completeMarkdown = async (completionContext: CompletionContext): Promise => { + const prefix = completionContext.matchBefore(/[@][@]\w+/); + if (!prefix || (prefix.from === prefix.to && !completionContext.explicit)) { + return null; } - const plugin = function(CodeMirror) { - CodeMirror.defineOption('quickLinks', false, function(cm, value, prev) { - if (!value) return; - - cm.on('inputRead', async function (cm1, change) { - if (!cm1.state.completionActive && cm.getTokenAt(cm.getCursor()).string === '@@') { - const start = {line: change.from.line, ch: change.from.ch + 1}; - - const hint = function(cm, callback) { - const cursor = cm.getCursor(); - let prefix = cm.getRange(start, cursor) || ''; - - buildHints(prefix).then(hints => { - callback({ - list: hints, - from: {line: change.from.line, ch: change.from.ch + 1}, - to: {line: change.to.line, ch: change.to.ch + 1}, - }); - }); - }; - - setTimeout(function () { - CodeMirror.showHint(cm, hint, { - completeSingle: false, - closeOnUnfocus: true, - async: true, - closeCharacters: /[()\[\]{};:>,]/ - }); - }, 10); - } - }); + const response = await pluginContext.postMessage({ + command: 'getNotes', + prefix: prefix.text, + }); + + const createApplyCompletionFn = (noteTitle: string, noteId: string) => { + return (view: EditorView, _completion: Completion, from: number, to: number) => { + const markdownLink = `[${noteTitle}](:/${noteId})`; + + view.dispatch( + insertCompletionText( + view.state, + markdownLink, + from, + to, + ), + ); + + if (response.selectText) { + const selStart = from + 1; + const selEnd = selStart + noteTitle.length; + view.dispatch({ + selection: EditorSelection.range(selStart, selEnd), + }); + } + }; + }; + + + const notes = response.notes; + const completions: Completion[] = []; + for (const note of notes) { + completions.push({ + apply: createApplyCompletionFn(note.title, note.id), + label: note.title, + detail: response.showFolders ? `In ${note.folder ?? 'unknown'}` : undefined, + }); + } + + const addNewNoteCompletion = (todo: boolean) => { + const title = prefix.text.substring(2); + const description = todo ? 'New Task' : 'New Note'; + completions.push({ + label: description, + detail: `"${title}"`, + apply: async (view, completion, from, to) => { + const response = await pluginContext.postMessage({ + command: 'createNote', + title, + todo, + }); + createApplyCompletionFn( + title, response.newNote.id + )(view, completion, from, to); + }, }); }; + if (response.allowNewNotes) { + addNewNoteCompletion(true); + addNewNoteCompletion(false); + } + return { - plugin: plugin, + from: prefix.from, + options: completions, + filter: false, + }; + }; + + CodeMirror.addExtension([ + autocompletion({ + activateOnTyping: true, + override: [ completeMarkdown ], + tooltipClass: () => 'quick-links-completions', + closeOnBlur: false, + }), + markdownLanguage.data.of({ + autocomplete: completeMarkdown, + }), + ]); +} + +module.exports = { + default: function(context: PluginContext) { + return { + plugin: (CodeMirror: any) => { + if (CodeMirror.cm6) { + return codeMirror6Plugin(context, CodeMirror); + } else { + return codeMirror5Plugin(context, CodeMirror); + } + }, codeMirrorResources: [ 'addon/hint/show-hint', - ], + ], codeMirrorOptions: { 'quickLinks': true, }, @@ -134,6 +242,6 @@ module.exports = { { name: './show-hint.css'}, ] } - } - } -} \ No newline at end of file + }; + }, +}; diff --git a/src/show-hint.css b/src/show-hint.css index 5617ccc..5f67a8f 100644 --- a/src/show-hint.css +++ b/src/show-hint.css @@ -1,4 +1,4 @@ -.CodeMirror-hints { +.CodeMirror-hints, .quick-links-completions.cm-tooltip { position: absolute; z-index: 10; overflow: hidden; @@ -13,7 +13,7 @@ border-radius: 3px; border: 1px solid silver; - background: white; + background-color: white; font-size: 90%; font-family: monospace; @@ -21,7 +21,11 @@ overflow-y: auto; } -.CodeMirror-hint { +.quick-links-completions.cm-tooltip li { + font-family: sans-serif; +} + +.CodeMirror-hint, .quick-links-completions li { margin: 0; padding: 0 4px; border-radius: 2px; @@ -30,7 +34,9 @@ cursor: pointer; } -li.CodeMirror-hint-active { +li.CodeMirror-hint-active, .quick-links-completions li[aria-selected] { background: #08f; color: white; } + + diff --git a/webpack.config.js b/webpack.config.js index d6052c5..cfd773b 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -6,15 +6,17 @@ // update, you can easily restore the functionality you've added. // ----------------------------------------------------------------------------- +/* eslint-disable no-console */ + const path = require('path'); const crypto = require('crypto'); const fs = require('fs-extra'); const chalk = require('chalk'); const CopyPlugin = require('copy-webpack-plugin'); -const WebpackOnBuildPlugin = require('on-build-webpack'); const tar = require('tar'); const glob = require('glob'); const execSync = require('child_process').execSync; +const allPossibleCategories = require('@joplin/lib/pluginCategories.json'); const rootDir = path.resolve(__dirname); const userConfigFilename = './plugin.config.json'; @@ -23,18 +25,31 @@ const distDir = path.resolve(rootDir, 'dist'); const srcDir = path.resolve(rootDir, 'src'); const publishDir = path.resolve(rootDir, 'publish'); -const userConfig = Object.assign({}, { - extraScripts: [], -}, fs.pathExistsSync(userConfigPath) ? require(userConfigFilename) : {}); +const userConfig = { extraScripts: [], ...(fs.pathExistsSync(userConfigPath) ? require(userConfigFilename) : {}) }; const manifestPath = `${srcDir}/manifest.json`; const packageJsonPath = `${rootDir}/package.json`; +const allPossibleScreenshotsType = ['jpg', 'jpeg', 'png', 'gif', 'webp']; const manifest = readManifest(manifestPath); const pluginArchiveFilePath = path.resolve(publishDir, `${manifest.id}.jpl`); const pluginInfoFilePath = path.resolve(publishDir, `${manifest.id}.json`); +const { builtinModules } = require('node:module'); + +// Webpack5 doesn't polyfill by default and displays a warning when attempting to require() built-in +// node modules. Set these to false to prevent Webpack from warning about not polyfilling these modules. +// We don't need to polyfill because the plugins run in Electron's Node environment. +const moduleFallback = {}; +for (const moduleName of builtinModules) { + moduleFallback[moduleName] = false; +} + +const getPackageJson = () => { + return JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); +}; + function validatePackageJson() { - const content = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + const content = getPackageJson(); if (!content.name || content.name.indexOf('joplin-plugin-') !== 0) { console.warn(chalk.yellow(`WARNING: To publish the plugin, the package name should start with "joplin-plugin-" (found "${content.name}") in ${packageJsonPath}`)); } @@ -67,15 +82,48 @@ function currentGitInfo() { } } +function validateCategories(categories) { + if (!categories) return null; + if ((categories.length !== new Set(categories).size)) throw new Error('Repeated categories are not allowed'); + // eslint-disable-next-line github/array-foreach -- Old code before rule was applied + categories.forEach(category => { + if (!allPossibleCategories.map(category => { return category.name; }).includes(category)) throw new Error(`${category} is not a valid category. Please make sure that the category name is lowercase. Valid categories are: \n${allPossibleCategories.map(category => { return category.name; })}\n`); + }); +} + +function validateScreenshots(screenshots) { + if (!screenshots) return null; + for (const screenshot of screenshots) { + if (!screenshot.src) throw new Error('You must specify a src for each screenshot'); + + // Avoid attempting to download and verify URL screenshots. + if (screenshot.src.startsWith('https://') || screenshot.src.startsWith('http://')) { + continue; + } + + const screenshotType = screenshot.src.split('.').pop(); + if (!allPossibleScreenshotsType.includes(screenshotType)) throw new Error(`${screenshotType} is not a valid screenshot type. Valid types are: \n${allPossibleScreenshotsType}\n`); + + const screenshotPath = path.resolve(rootDir, screenshot.src); + + // Max file size is 1MB + const fileMaxSize = 1024; + const fileSize = fs.statSync(screenshotPath).size / 1024; + if (fileSize > fileMaxSize) throw new Error(`Max screenshot file size is ${fileMaxSize}KB. ${screenshotPath} is ${fileSize}KB`); + } +} + function readManifest(manifestPath) { const content = fs.readFileSync(manifestPath, 'utf8'); const output = JSON.parse(content); if (!output.id) throw new Error(`Manifest plugin ID is not set in ${manifestPath}`); + validateCategories(output.categories); + validateScreenshots(output.screenshots); return output; } function createPluginArchive(sourceDir, destPath) { - const distFiles = glob.sync(`${sourceDir}/**/*`, { nodir: true }) + const distFiles = glob.sync(`${sourceDir}/**/*`, { nodir: true, windowsPathsNoEscape: true }) .map(f => f.substr(sourceDir.length + 1)); if (!distFiles.length) throw new Error('Plugin archive was not created because the "dist" directory is empty'); @@ -89,18 +137,22 @@ function createPluginArchive(sourceDir, destPath) { cwd: sourceDir, sync: true, }, - distFiles + distFiles, ); console.info(chalk.cyan(`Plugin archive has been created in ${destPath}`)); } +const writeManifest = (manifestPath, content) => { + fs.writeFileSync(manifestPath, JSON.stringify(content, null, '\t'), 'utf8'); +}; + function createPluginInfo(manifestPath, destPath, jplFilePath) { const contentText = fs.readFileSync(manifestPath, 'utf8'); const content = JSON.parse(contentText); content._publish_hash = `sha256:${fileSha256(jplFilePath)}`; content._publish_commit = currentGitInfo(); - fs.writeFileSync(destPath, JSON.stringify(content, null, '\t'), 'utf8'); + writeManifest(destPath, content); } function onBuildCompleted() { @@ -129,15 +181,15 @@ const baseConfig = { }, }; -const pluginConfig = Object.assign({}, baseConfig, { - entry: './src/index.ts', +const pluginConfig = { ...baseConfig, entry: './src/index.ts', resolve: { alias: { api: path.resolve(__dirname, 'api'), }, + fallback: moduleFallback, // JSON files can also be required from scripts so we include this. // https://github.com/joplin/plugin-bibtex/pull/2 - extensions: ['.tsx', '.ts', '.js', '.json'], + extensions: ['.js', '.tsx', '.ts', '.json'], }, output: { filename: 'index.js', @@ -161,26 +213,62 @@ const pluginConfig = Object.assign({}, baseConfig, { }, ], }), - ], -}); + ] }; + + +// These libraries can be included with require(...) or +// joplin.require(...) from content scripts. +const externalContentScriptLibraries = [ + '@codemirror/view', + '@codemirror/state', + '@codemirror/language', + '@codemirror/autocomplete', + '@codemirror/commands', + '@codemirror/highlight', + '@codemirror/lint', + '@codemirror/lang-html', + '@codemirror/lang-markdown', + '@codemirror/language-data', + '@lezer/common', + '@lezer/markdown', + '@lezer/highlight', +]; + +const extraScriptExternals = {}; +for (const library of externalContentScriptLibraries) { + extraScriptExternals[library] = { commonjs: library }; +} -const extraScriptConfig = Object.assign({}, baseConfig, { +const extraScriptConfig = { + ...baseConfig, resolve: { alias: { api: path.resolve(__dirname, 'api'), }, - extensions: ['.tsx', '.ts', '.js', '.json'], + fallback: moduleFallback, + extensions: ['.js', '.tsx', '.ts', '.json'], }, -}); + + // We support requiring @codemirror/... libraries through require('@codemirror/...') + externalsType: 'commonjs', + externals: extraScriptExternals, +}; const createArchiveConfig = { stats: 'errors-only', entry: './dist/index.js', + resolve: { + fallback: moduleFallback, + }, output: { filename: 'index.js', path: publishDir, }, - plugins: [new WebpackOnBuildPlugin(onBuildCompleted)], + plugins: [{ + apply(compiler) { + compiler.hooks.done.tap('archiveOnBuildListener', onBuildCompleted); + }, + }], }; function resolveExtraScriptPath(name) { @@ -212,20 +300,41 @@ function buildExtraScriptConfigs(userConfig) { for (const scriptName of userConfig.extraScripts) { const scriptPaths = resolveExtraScriptPath(scriptName); - output.push(Object.assign({}, extraScriptConfig, { - entry: scriptPaths.entry, - output: scriptPaths.output, - })); + output.push({ ...extraScriptConfig, entry: scriptPaths.entry, + output: scriptPaths.output }); } return output; } -function main(processArgv) { - const yargs = require('yargs/yargs'); - const argv = yargs(processArgv).argv; +const increaseVersion = version => { + try { + const s = version.split('.'); + const d = Number(s[s.length - 1]) + 1; + s[s.length - 1] = `${d}`; + return s.join('.'); + } catch (error) { + error.message = `Could not parse version number: ${version}: ${error.message}`; + throw error; + } +}; + +const updateVersion = () => { + const packageJson = getPackageJson(); + packageJson.version = increaseVersion(packageJson.version); + fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`, 'utf8'); + + const manifest = readManifest(manifestPath); + manifest.version = increaseVersion(manifest.version); + writeManifest(manifestPath, manifest); + + if (packageJson.version !== manifest.version) { + console.warn(chalk.yellow(`Version numbers have been updated but they do not match: package.json (${packageJson.version}), manifest.json (${manifest.version}). Set them to the required values to get them in sync.`)); + } +}; - const configName = argv['joplin-plugin-config']; +function main(environ) { + const configName = environ['joplin-plugin-config']; if (!configName) throw new Error('A config file must be specified via the --joplin-plugin-config flag'); // Webpack configurations run in parallel, while we need them to run in @@ -260,22 +369,30 @@ function main(processArgv) { fs.mkdirpSync(publishDir); } + if (configName === 'updateVersion') { + updateVersion(); + return []; + } + return configs[configName]; } -let exportedConfigs = []; -try { - exportedConfigs = main(process.argv); -} catch (error) { - console.error(chalk.red(error.message)); - process.exit(1); -} +module.exports = (env) => { + let exportedConfigs = []; -if (!exportedConfigs.length) { - // Nothing to do - for example where there are no external scripts to - // compile. - process.exit(0); -} + try { + exportedConfigs = main(env); + } catch (error) { + console.error(error.message); + process.exit(1); + } -module.exports = exportedConfigs; + if (!exportedConfigs.length) { + // Nothing to do - for example where there are no external scripts to + // compile. + process.exit(0); + } + + return exportedConfigs; +};