From 8be22ed910a5d31703dec8848140b06ad905c645 Mon Sep 17 00:00:00 2001 From: Laurent Cozic Date: Fri, 3 Nov 2023 19:45:21 +0000 Subject: [PATCH] Plugins: Add support for getting plugin settings from a Markdown renderer --- .../tests/services/plugins/PluginService.ts | 2 +- .../app-desktop/gui/NoteEditor/NoteEditor.tsx | 6 ++++ .../gui/NoteEditor/utils/useMarkupToHtml.ts | 9 +++-- packages/app-desktop/testPluginDemo.sh | 4 +-- .../app/templates/api/JoplinSettings.d.ts | 3 +- .../generators/app/templates/api/types.ts | 27 +++++++++++--- .../services/plugins/api/JoplinSettings.ts | 35 ++++++++++--------- packages/lib/services/plugins/api/types.ts | 27 +++++++++++--- .../plugins/utils/loadContentScripts.ts | 8 +++-- packages/renderer/MdToHtml.ts | 26 ++++++++++---- packages/renderer/package.json | 1 + yarn.lock | 1 + 12 files changed, 105 insertions(+), 44 deletions(-) diff --git a/packages/app-cli/tests/services/plugins/PluginService.ts b/packages/app-cli/tests/services/plugins/PluginService.ts index 722a7288a..d5d0a715e 100644 --- a/packages/app-cli/tests/services/plugins/PluginService.ts +++ b/packages/app-cli/tests/services/plugins/PluginService.ts @@ -206,7 +206,7 @@ describe('services_PluginService', () => { const mdToHtml = new MdToHtml(); const module = require(contentScript.path).default; - mdToHtml.loadExtraRendererRule(contentScript.id, tempDir, module({})); + mdToHtml.loadExtraRendererRule(contentScript.id, tempDir, module({}), ''); const result = await mdToHtml.render([ '```justtesting', diff --git a/packages/app-desktop/gui/NoteEditor/NoteEditor.tsx b/packages/app-desktop/gui/NoteEditor/NoteEditor.tsx index b0bcfdec0..94d63b9ee 100644 --- a/packages/app-desktop/gui/NoteEditor/NoteEditor.tsx +++ b/packages/app-desktop/gui/NoteEditor/NoteEditor.tsx @@ -48,6 +48,7 @@ import ItemChange from '@joplin/lib/models/ItemChange'; import PlainEditor from './NoteBody/PlainEditor/PlainEditor'; import CodeMirror6 from './NoteBody/CodeMirror/v6/CodeMirror'; import CodeMirror5 from './NoteBody/CodeMirror/v5/CodeMirror'; +import { namespacedKey } from '@joplin/lib/services/plugins/api/JoplinSettings'; const commands = [ require('./commands/showRevisions'), @@ -159,10 +160,15 @@ function NoteEditor(props: NoteEditorProps) { return formNote.saveActionQueue.waitForAllDone(); } + const settingValue = useCallback((pluginId: string, key: string) => { + return Setting.value(namespacedKey(pluginId, key)); + }, []); + const markupToHtml = useMarkupToHtml({ themeId: props.themeId, customCss: props.customCss, plugins: props.plugins, + settingValue, }); const allAssets = useCallback(async (markupLanguage: number, options: AllAssetsOptions = null): Promise => { diff --git a/packages/app-desktop/gui/NoteEditor/utils/useMarkupToHtml.ts b/packages/app-desktop/gui/NoteEditor/utils/useMarkupToHtml.ts index a9b5673f8..c66175fab 100644 --- a/packages/app-desktop/gui/NoteEditor/utils/useMarkupToHtml.ts +++ b/packages/app-desktop/gui/NoteEditor/utils/useMarkupToHtml.ts @@ -12,6 +12,7 @@ interface HookDependencies { themeId: number; customCss: string; plugins: PluginStates; + settingValue: (pluginId: string, key: string)=> any; } export interface MarkupToHtmlOptions { @@ -59,12 +60,16 @@ export default function useMarkupToHtml(deps: HookDependencies) { delete options.replaceResourceInternalToExternalLinks; - const result = await markupToHtml.render(markupLanguage, md, theme, { codeTheme: theme.codeThemeCss, + const result = await markupToHtml.render(markupLanguage, md, theme, { + codeTheme: theme.codeThemeCss, resources: resources, postMessageSyntax: 'ipcProxySendToHost', splitted: true, externalAssetsOnly: true, - codeHighlightCacheKey: 'useMarkupToHtml', ...options }); + codeHighlightCacheKey: 'useMarkupToHtml', + settingValue: deps.settingValue, + ...options, + }); return result; // eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied diff --git a/packages/app-desktop/testPluginDemo.sh b/packages/app-desktop/testPluginDemo.sh index ce960cd71..53312c0b8 100755 --- a/packages/app-desktop/testPluginDemo.sh +++ b/packages/app-desktop/testPluginDemo.sh @@ -5,8 +5,8 @@ SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" TEMP_PATH=~/src/plugin-tests -NEED_COMPILING=0 -PLUGIN_PATH=~/src/joplin/packages/app-cli/tests/support/plugins/simple +NEED_COMPILING=1 +PLUGIN_PATH=~/src/plugin-abc if [[ $NEED_COMPILING == 1 ]]; then mkdir -p "$TEMP_PATH" diff --git a/packages/generator-joplin/generators/app/templates/api/JoplinSettings.d.ts b/packages/generator-joplin/generators/app/templates/api/JoplinSettings.d.ts index 32ce9d4cb..2d04aa2a9 100644 --- a/packages/generator-joplin/generators/app/templates/api/JoplinSettings.d.ts +++ b/packages/generator-joplin/generators/app/templates/api/JoplinSettings.d.ts @@ -7,6 +7,7 @@ export interface ChangeEvent { keys: string[]; } export type ChangeHandler = (event: ChangeEvent) => void; +export declare const namespacedKey: (pluginId: string, key: string) => string; /** * This API allows registering new settings and setting sections, as well as getting and setting settings. Once a setting has been registered it will appear in the config screen and be editable by the user. * @@ -19,8 +20,6 @@ export type ChangeHandler = (event: ChangeEvent) => void; export default class JoplinSettings { private plugin_; constructor(plugin: Plugin); - private get keyPrefix(); - private namespacedKey; /** * Registers new settings. * Note that registering a setting item is dynamic and will be gone next time Joplin starts. diff --git a/packages/generator-joplin/generators/app/templates/api/types.ts b/packages/generator-joplin/generators/app/templates/api/types.ts index dffd9b24e..70f15940c 100644 --- a/packages/generator-joplin/generators/app/templates/api/types.ts +++ b/packages/generator-joplin/generators/app/templates/api/types.ts @@ -519,6 +519,20 @@ export interface ContentScriptContext { postMessage: PostMessageHandler; } +export interface ContentScriptModuleLoadedEvent { + userData?: any; +} + +export interface ContentScriptModule { + onLoaded?: (event:ContentScriptModuleLoadedEvent) => void; + plugin: () => any; + assets?: () => void; +} + +export interface MarkdownItContentScriptModule extends Omit { + plugin: (markdownIt:any, options:any) => any; +} + export enum ContentScriptType { /** * Registers a new Markdown-It plugin, which should follow the template @@ -528,7 +542,7 @@ export enum ContentScriptType { * module.exports = { * default: function(context) { * return { - * plugin: function(markdownIt, options) { + * plugin: function(markdownIt, pluginOptions) { * // ... * }, * assets: { @@ -538,6 +552,7 @@ export enum ContentScriptType { * } * } * ``` + * * See [the * demo](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/content_script) * for a simple Markdown-it plugin example. @@ -550,10 +565,7 @@ export enum ContentScriptType { * * - The **required** `plugin` key is the actual Markdown-It plugin - check * the [official doc](https://github.com/markdown-it/markdown-it) for more - * information. The `options` parameter is of type - * [RuleOptions](https://github.com/laurent22/joplin/blob/dev/packages/renderer/MdToHtml.ts), - * which contains a number of options, mostly useful for Joplin's internal - * code. + * information. * * - Using the **optional** `assets` key you may specify assets such as JS * or CSS that should be loaded in the rendered HTML document. Check for @@ -561,6 +573,11 @@ export enum ContentScriptType { * plugin](https://github.com/laurent22/joplin/blob/dev/packages/renderer/MdToHtml/rules/mermaid.ts) * to see how the data should be structured. * + * ## Getting the settings from the renderer + * + * You can access your plugin settings from the renderer by calling + * `pluginOptions.settingValue("your-setting-key')`. + * * ## Posting messages from the content script to your plugin * * The application provides the following function to allow executing diff --git a/packages/lib/services/plugins/api/JoplinSettings.ts b/packages/lib/services/plugins/api/JoplinSettings.ts index 39ee61174..0fd92c2b2 100644 --- a/packages/lib/services/plugins/api/JoplinSettings.ts +++ b/packages/lib/services/plugins/api/JoplinSettings.ts @@ -70,6 +70,17 @@ export interface ChangeEvent { export type ChangeHandler = (event: ChangeEvent)=> void; +const keyPrefix = (pluginId: string): string => { + return `plugin-${pluginId}.`; +}; + +// Ensures that the plugin settings and sections are within their own namespace, +// to prevent them from overwriting other plugin settings or the default +// settings. +export const namespacedKey = (pluginId: string, key: string): string => { + return `${keyPrefix(pluginId)}${key}`; +}; + /** * This API allows registering new settings and setting sections, as well as getting and setting settings. Once a setting has been registered it will appear in the config screen and be editable by the user. * @@ -86,16 +97,6 @@ export default class JoplinSettings { this.plugin_ = plugin; } - private get keyPrefix(): string { - return `plugin-${this.plugin_.id}.`; - } - - // Ensures that the plugin settings and sections are within their own namespace, to prevent them from - // overwriting other plugin settings or the default settings. - private namespacedKey(key: string): string { - return `${this.keyPrefix}${key}`; - } - /** * Registers new settings. * Note that registering a setting item is dynamic and will be gone next time Joplin starts. @@ -116,7 +117,7 @@ export default class JoplinSettings { if ('subType' in setting) internalSettingItem.subType = setting.subType; if ('isEnum' in setting) internalSettingItem.isEnum = setting.isEnum; - if ('section' in setting) internalSettingItem.section = this.namespacedKey(setting.section); + if ('section' in setting) internalSettingItem.section = namespacedKey(this.plugin_.id, setting.section); if ('options' in setting) internalSettingItem.options = () => setting.options; if ('appTypes' in setting) internalSettingItem.appTypes = setting.appTypes; if ('secure' in setting) internalSettingItem.secure = setting.secure; @@ -126,7 +127,7 @@ export default class JoplinSettings { if ('step' in setting) internalSettingItem.step = setting.step; if ('storage' in setting) internalSettingItem.storage = setting.storage; - await Setting.registerSetting(this.namespacedKey(key), internalSettingItem); + await Setting.registerSetting(namespacedKey(this.plugin_.id, key), internalSettingItem); } } @@ -150,21 +151,21 @@ export default class JoplinSettings { * Registers a new setting section. Like for registerSetting, it is dynamic and needs to be done every time the plugin starts. */ public async registerSection(name: string, section: SettingSection) { - return Setting.registerSection(this.namespacedKey(name), SettingSectionSource.Plugin, section); + return Setting.registerSection(namespacedKey(this.plugin_.id, name), SettingSectionSource.Plugin, section); } /** * Gets a setting value (only applies to setting you registered from your plugin) */ public async value(key: string): Promise { - return Setting.value(this.namespacedKey(key)); + return Setting.value(namespacedKey(this.plugin_.id, key)); } /** * Sets a setting value (only applies to setting you registered from your plugin) */ public async setValue(key: string, value: any) { - return Setting.setValue(this.namespacedKey(key), value); + return Setting.setValue(namespacedKey(this.plugin_.id, key), value); } /** @@ -187,8 +188,8 @@ export default class JoplinSettings { // Filter out keys that are not related to this plugin eventManager.on('settingsChange', (event: ChangeEvent) => { const keys = event.keys - .filter(k => k.indexOf(this.keyPrefix) === 0) - .map(k => k.substr(this.keyPrefix.length)); + .filter(k => k.indexOf(keyPrefix(this.plugin_.id)) === 0) + .map(k => k.substr(keyPrefix(this.plugin_.id).length)); if (!keys.length) return; handler({ keys }); }); diff --git a/packages/lib/services/plugins/api/types.ts b/packages/lib/services/plugins/api/types.ts index dffd9b24e..e439b5246 100644 --- a/packages/lib/services/plugins/api/types.ts +++ b/packages/lib/services/plugins/api/types.ts @@ -519,6 +519,20 @@ export interface ContentScriptContext { postMessage: PostMessageHandler; } +export interface ContentScriptModuleLoadedEvent { + userData?: any; +} + +export interface ContentScriptModule { + onLoaded?: (event: ContentScriptModuleLoadedEvent)=> void; + plugin: ()=> any; + assets?: ()=> void; +} + +export interface MarkdownItContentScriptModule extends Omit { + plugin: (markdownIt: any, options: any)=> any; +} + export enum ContentScriptType { /** * Registers a new Markdown-It plugin, which should follow the template @@ -528,7 +542,7 @@ export enum ContentScriptType { * module.exports = { * default: function(context) { * return { - * plugin: function(markdownIt, options) { + * plugin: function(markdownIt, pluginOptions) { * // ... * }, * assets: { @@ -538,6 +552,7 @@ export enum ContentScriptType { * } * } * ``` + * * See [the * demo](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/content_script) * for a simple Markdown-it plugin example. @@ -550,10 +565,7 @@ export enum ContentScriptType { * * - The **required** `plugin` key is the actual Markdown-It plugin - check * the [official doc](https://github.com/markdown-it/markdown-it) for more - * information. The `options` parameter is of type - * [RuleOptions](https://github.com/laurent22/joplin/blob/dev/packages/renderer/MdToHtml.ts), - * which contains a number of options, mostly useful for Joplin's internal - * code. + * information. * * - Using the **optional** `assets` key you may specify assets such as JS * or CSS that should be loaded in the rendered HTML document. Check for @@ -561,6 +573,11 @@ export enum ContentScriptType { * plugin](https://github.com/laurent22/joplin/blob/dev/packages/renderer/MdToHtml/rules/mermaid.ts) * to see how the data should be structured. * + * ## Getting the settings from the renderer + * + * You can access your plugin settings from the renderer by calling + * `pluginOptions.settingValue("your-setting-key')`. + * * ## Posting messages from the content script to your plugin * * The application provides the following function to allow executing diff --git a/packages/lib/services/plugins/utils/loadContentScripts.ts b/packages/lib/services/plugins/utils/loadContentScripts.ts index 3355423b2..cbdf5d9c8 100644 --- a/packages/lib/services/plugins/utils/loadContentScripts.ts +++ b/packages/lib/services/plugins/utils/loadContentScripts.ts @@ -1,5 +1,5 @@ import { PluginStates } from '../reducer'; -import { ContentScriptType, ContentScriptContext, PostMessageHandler } from '../api/types'; +import { ContentScriptType, ContentScriptContext, PostMessageHandler, ContentScriptModule } from '../api/types'; import { dirname } from '@joplin/renderer/pathUtils'; import shim from '../../../shim'; import Logger from '@joplin/utils/Logger'; @@ -11,6 +11,7 @@ export interface ExtraContentScript { id: string; module: any; assetPath: string; + pluginId: string; } function postMessageHandler(pluginId: string, scriptType: ContentScriptType, contentScriptId: string): PostMessageHandler { @@ -53,14 +54,15 @@ function loadContentScripts(plugins: PluginStates, scriptType: ContentScriptType postMessage: postMessageHandler(pluginId, scriptType, contentScript.id), }; - const loadedModule = module.default(context); + const loadedModule = module.default(context) as ContentScriptModule; - if (!loadedModule.plugin && !loadedModule.codeMirrorResources && !loadedModule.codeMirrorOptions) throw new Error(`Content script must export a "plugin" key or a list of CodeMirror assets or define a CodeMirror option: Plugin: ${pluginId}: Script: ${contentScript.id}`); + if (!loadedModule.plugin && !(loadedModule as any).codeMirrorResources && !(loadedModule as any).codeMirrorOptions) throw new Error(`Content script must export a "plugin" key or a list of CodeMirror assets or define a CodeMirror option: Plugin: ${pluginId}: Script: ${contentScript.id}`); output.push({ id: contentScript.id, module: loadedModule, assetPath: dirname(contentScript.path), + pluginId, }); } catch (error) { // This function must not throw as doing so would crash the diff --git a/packages/renderer/MdToHtml.ts b/packages/renderer/MdToHtml.ts index 64178da88..3161ff565 100644 --- a/packages/renderer/MdToHtml.ts +++ b/packages/renderer/MdToHtml.ts @@ -7,10 +7,10 @@ import { ItemIdToUrlHandler } from './utils'; import { RenderResult, RenderResultPluginAsset } from './MarkupToHtml'; import { Options as NoteStyleOptions } from './noteStyle'; import hljs from './highlight'; +import * as MarkdownIt from 'markdown-it'; const Entities = require('html-entities').AllHtmlEntities; const htmlentities = new Entities().encode; -const MarkdownIt = require('markdown-it'); const md5 = require('md5'); export interface RenderOptions { @@ -32,6 +32,7 @@ export interface RenderOptions { useCustomPdfViewer?: boolean; noteId?: string; vendorDir?: string; + settingValue?: (pluginId: string, key: string)=> any; } interface RendererRule { @@ -40,6 +41,7 @@ interface RendererRule { plugin?: any; assetPath?: string; assetPathIsAbsolute?: boolean; + pluginId?: string; } interface RendererRules { @@ -102,6 +104,7 @@ export interface ExtraRendererRule { id: string; module: any; assetPath: string; + pluginId: string; } export interface Options { @@ -233,7 +236,7 @@ export default class MdToHtml { if (options.extraRendererRules) { for (const rule of options.extraRendererRules) { - this.loadExtraRendererRule(rule.id, rule.assetPath, rule.module); + this.loadExtraRendererRule(rule.id, rule.assetPath, rule.module, rule.pluginId); } } @@ -268,11 +271,13 @@ export default class MdToHtml { } // `module` is a file that has already been `required()` - public loadExtraRendererRule(id: string, assetPath: string, module: any) { + public loadExtraRendererRule(id: string, assetPath: string, module: any, pluginId: string) { if (this.extraRendererRules_[id]) throw new Error(`A renderer rule with this ID has already been loaded: ${id}`); + this.extraRendererRules_[id] = { ...module, assetPath, + pluginId: pluginId, assetPathIsAbsolute: true, }; } @@ -451,6 +456,7 @@ export default class MdToHtml { pdfViewerEnabled: this.pluginEnabled('pdfViewer'), contentMaxWidth: 0, + settingValue: (_pluginId: string, _key: string) => { throw new Error('settingValue is not implemented'); }, ...options, }; @@ -467,8 +473,11 @@ export default class MdToHtml { const cachedOutput = this.cachedOutputs_[cacheKey]; if (cachedOutput) return cachedOutput; - const ruleOptions = { ...options, resourceBaseUrl: this.resourceBaseUrl_, - ResourceModel: this.ResourceModel_ }; + const ruleOptions = { + ...options, + resourceBaseUrl: this.resourceBaseUrl_, + ResourceModel: this.ResourceModel_, + }; const context: PluginContext = { css: {}, @@ -478,12 +487,12 @@ export default class MdToHtml { currentLinks: [], }; - const markdownIt = new MarkdownIt({ + const markdownIt: MarkdownIt = new MarkdownIt({ breaks: !this.pluginEnabled('softbreaks'), typographer: this.pluginEnabled('typographer'), linkify: this.pluginEnabled('linkify'), html: true, - highlight: (str: string, lang: string) => { + highlight: (str: string, lang: string, _attrs: any): any => { let outputCodeHtml = ''; // The strings includes the last \n that is part of the fence, @@ -567,6 +576,9 @@ export default class MdToHtml { context: context, ...ruleOptions, ...(ruleOptions.plugins[key] ? ruleOptions.plugins[key] : {}), + settingValue: (key: string) => { + return options.settingValue(rule.pluginId, key); + }, }); } diff --git a/packages/renderer/package.json b/packages/renderer/package.json index 0fba3524a..b9bb9ddfd 100644 --- a/packages/renderer/package.json +++ b/packages/renderer/package.json @@ -20,6 +20,7 @@ "license": "AGPL-3.0-or-later", "devDependencies": { "@types/jest": "29.5.5", + "@types/markdown-it": "13.0.2", "@types/node": "18.17.19", "jest": "29.7.0", "jest-environment-jsdom": "29.7.0", diff --git a/yarn.lock b/yarn.lock index e7421ed76..553652754 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6668,6 +6668,7 @@ __metadata: "@joplin/fork-uslug": ^1.0.11 "@joplin/utils": ~2.13 "@types/jest": 29.5.5 + "@types/markdown-it": 13.0.2 "@types/node": 18.17.19 font-awesome-filetypes: 2.1.0 fs-extra: 11.1.1