From 5a469f58aef26c09d5e21950f961339de9cb7d1d Mon Sep 17 00:00:00 2001 From: Eric Schubert <38206611+Kharonus@users.noreply.github.com> Date: Tue, 12 May 2026 15:27:04 +0200 Subject: [PATCH 1/2] [#74710] add wiki page link macro plugin - https://community.openproject.org/work_packages/74710 - add upcast and downcast conversions - add parsing function to data prozessor - use path helper for frame src url --- .editorconfig | 14 ++ .gitignore | 1 + README.md | 59 ++++----- docker-compose.yml | 23 ++++ src/commonmark/commonmark.js | 2 +- src/commonmark/commonmarkdataprocessor.js | 123 +++++++++++++----- src/op-plugins.js | 2 + .../op-macro-wiki-page-link-plugin.js | 87 +++++++++++++ 8 files changed, 245 insertions(+), 66 deletions(-) create mode 100644 docker-compose.yml create mode 100644 src/plugins/op-macro-wiki-page-link/op-macro-wiki-page-link-plugin.js diff --git a/.editorconfig b/.editorconfig index 541fc2d..81b994e 100644 --- a/.editorconfig +++ b/.editorconfig @@ -10,3 +10,17 @@ charset = utf-8 end_of_line = lf trim_trailing_whitespace = true insert_final_newline = true + +[*.md] +indent_style = space +indent_size = 4 + +[*.yml] +indent_style = space +indent_size = 2 + +[*.js] +ij_javascript_spaces_within_object_literal_braces = true +ij_javascript_keep_simple_blocks_in_one_line = true +ij_javascript_keep_simple_methods_in_one_line = true +ij_javascript_spaces_within_imports = true diff --git a/.gitignore b/.gitignore index e6767d9..b684faf 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ build/ node_modules/ .idea .direnv +.env coverage/ diff --git a/README.md b/README.md index d1db972..2362d11 100644 --- a/README.md +++ b/README.md @@ -2,69 +2,70 @@ This repository acts as a separated source for the custom CKEditor5 builds referenced in OpenProject. - [https://github.com/opf/openproject](https://github.com/opf/openproject) [https://github.com/ckeditor/ckeditor5](https://github.com/ckeditor/ckeditor5) +## Setup +### Install the dependencies -1. Install the dependencies - -``` +```shell # In this repository's root (commonmark-ckeditor-build) npm install +# Or with docker: +docker compose run --rm install ``` -2. Reference the link in OpenProject +### Reference the link in OpenProject -``` +```shell export OPENPROJECT_CORE=/path/to/openproject/root ``` - +If using the docker compose services, the `OPENPROJECT_CORE` environment variable must be set in the `.env` file. ## Building +To build for the OpenProject core, run `npm run build` or `docker compose run --rm build`. This will override the +`frontend/src/vendor/ckeditor/*` contents in the core repository with the newest webpack build. You need to run this +before opening a pull request. -Building into the core is easy, just run - -`npm run build` - - -This will override the `app/assets/javascripts/vendor/ckeditor/*` contents with the newest webpack build. You need to run this before opening a pull request. - -> [!important] -> Please ensure that for any changes in this repository, you have a core repository with the output of `npm run build`, so that all core tests can run and confirm your changes. Both pull requests should _always_ be merged at the same time, never alone - +> [!IMPORTANT] +> Please ensure that for any changes in this repository, you have a core repository pull request with the output +> of `npm run build`, so that all core tests can run and confirm your changes. Both pull requests should _always_ be +> merged at the same time, never alone. ### Updating CKEditor -Whenever a new CKEditor release is made, there are a plethora of packages to be updated. The easiest is to use [npm-check-updates](https://www.npmjs.com/package/npm-check-updates) to update all dependencies in the package.json and then rebuild + run openproject tests. - - +Whenever a new CKEditor release is made, there are a plethora of packages to be updated. The easiest is to +use [npm-check-updates](https://www.npmjs.com/package/npm-check-updates) to update all dependencies in the package.json +and then rebuild + run openproject tests. ### Patch for ckeditor5-mention plugin -We use `patch-package` (https://www.npmjs.com/package/patch-package) to store a patch for the ckeditor5-mention plugin to ensure multiple-hash mentions for work packages (e.g., `###2134`) work correctly. See https://community.openproject.org/work_packages/47084 for context. - - +We use `patch-package` (https://www.npmjs.com/package/patch-package) to store a patch for the ckeditor5-mention plugin +to ensure multiple-hash mentions for work packages (e.g., `###2134`) work correctly. +See https://community.openproject.org/work_packages/47084 for context. ## Development - Run `npm run watch` +- Alternatively, run `docker compose up -d watch` -Now the webpack development mode is building the files and outputting them to `app/assets/javascripts/vendor/ckeditor/*`, overriding anything in there. - - +Now the webpack development mode is building the files and outputting them to `frontend/src/vendor/ckeditor/*` in the +core repository, overriding anything in there. ## Migration Notes ### jQuery Removal -As of version 11.2.0, this library no longer uses jQuery internally. All jQuery dependencies have been replaced with vanilla JavaScript equivalents using Request.JS and native DOM manipulation. +As of version 11.2.0, this library no longer uses jQuery internally. All jQuery dependencies have been replaced with +vanilla JavaScript equivalents using Request.JS and native DOM manipulation. -**Important for downstream consumers (e.g., OpenProject):** While this library no longer uses jQuery internally, downstream applications should continue to expose the jQuery global if other parts of the application depend on it. Do not remove the jQuery global from the downstream application (OpenProject) yet until all components have been migrated. +**Important for downstream consumers (e.g., OpenProject):** While this library no longer uses jQuery internally, +downstream applications should continue to expose the jQuery global if other parts of the application depend on it. Do +not remove the jQuery global from the downstream application (OpenProject) yet until all components have been migrated. -For more details on the downstream migration, see: https://github.com/opf/openproject/pull/19429 +For more details on the downstream migration, see: https://github.com/opf/openproject/pull/19429. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..bbec72c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,23 @@ +x-op-node: &op-node + image: node:lts + working_dir: /home/node/app + volumes: + - ./:/home/node/app + # The absolute path to the core repository MUST be set in the `.env`, + # otherwise the build won't replace the code in there. + - ${OPENPROJECT_CORE:-/tmp}/frontend/src/vendor/ckeditor:/openproject/frontend/src/vendor/ckeditor + environment: + OPENPROJECT_CORE: /openproject + +services: + install: + <<: *op-node + command: npm install + + build: + <<: *op-node + command: npm run build + + watch: + <<: *op-node + command: npm run watch diff --git a/src/commonmark/commonmark.js b/src/commonmark/commonmark.js index 4c3d718..74c4bbf 100644 --- a/src/commonmark/commonmark.js +++ b/src/commonmark/commonmark.js @@ -7,6 +7,6 @@ import CommonMarkDataProcessor from './commonmarkdataprocessor'; // Simple plugin which loads the data processor. export default function CommonMarkPlugin(editor) { - editor.data.processor = new CommonMarkDataProcessor(editor.editing.view.document); + editor.data.processor = new CommonMarkDataProcessor(editor); } diff --git a/src/commonmark/commonmarkdataprocessor.js b/src/commonmark/commonmarkdataprocessor.js index 3ed3388..0455285 100644 --- a/src/commonmark/commonmarkdataprocessor.js +++ b/src/commonmark/commonmarkdataprocessor.js @@ -9,16 +9,17 @@ /* eslint-env browser */ -import {HtmlDataProcessor, ViewDomConverter} from '@ckeditor/ckeditor5-engine'; -import {highlightedCodeBlock} from 'turndown-plugin-gfm'; +import { HtmlDataProcessor, ViewDomConverter } from '@ckeditor/ckeditor5-engine'; +import { highlightedCodeBlock } from 'turndown-plugin-gfm'; import TurndownService from 'turndown'; -import {textNodesPreprocessor, linkPreprocessor, breaksPreprocessor} from './utils/preprocessor'; -import {fixTasklistWhitespaces} from './utils/fix-tasklist-whitespaces'; -import {hoistTaskListCheckboxes} from './utils/hoist-task-list-checkboxes'; -import {fixBreaksInTables, fixBreaksInLists, fixBreaksOnRootLevel} from "./utils/fix-breaks"; +import { textNodesPreprocessor, linkPreprocessor, breaksPreprocessor } from './utils/preprocessor'; +import { fixTasklistWhitespaces } from './utils/fix-tasklist-whitespaces'; +import { hoistTaskListCheckboxes } from './utils/hoist-task-list-checkboxes'; +import { fixBreaksInTables, fixBreaksInLists, fixBreaksOnRootLevel } from "./utils/fix-breaks"; import markdownIt from 'markdown-it'; import markdownItTaskLists from 'markdown-it-task-lists'; -import {isPageBreakNode, PAGE_BREAK_MARKDOWN} from "./utils/page-breaks"; +import { isPageBreakNode, PAGE_BREAK_MARKDOWN } from "./utils/page-breaks"; +import { getOPPath } from "../plugins/op-context/op-context"; export const originalSrcAttribute = 'data-original-src'; @@ -54,15 +55,51 @@ function workPackageRefInlineRule(state, silent) { return true; } +const WIKI_PAGE_LINK_RE = /(?:\\\[){3}([0-9]+):([^\\\n]+)(?:\\\]){3}/; + +function wikiPageLinkInlineRule(state, silent, editor) { + const start = state.pos; + const src = state.src; + + if (src.charCodeAt(start) !== 0x5c /* \ */) return false; + + const word = src.slice(start); + const match = WIKI_PAGE_LINK_RE.exec(word); + if (!match) return false; + if (silent) return true; + + const providerId = match[1]; + const pageIdentifier = match[2]; + + const frameId = crypto.randomUUID(); + const frameSrc = getOPPath(editor).wikiPageLinkMacro(providerId, pageIdentifier, frameId) + + const html = ` +` + + const token = state.push('html_inline', '', 0); + token.content = html; + state.pos = start + match[0].length; + return true; +} + /** * This data processor implementation uses CommonMark as input/output data. * * @implements module:engine/dataprocessor/dataprocessor~DataProcessor */ export default class CommonMarkDataProcessor { - constructor(document) { + constructor(editor) { + const document = editor.editing.view.document; this._htmlDP = new HtmlDataProcessor(document); this._domConverter = new ViewDomConverter(document); + this.editor = editor; } /** @@ -81,9 +118,14 @@ export default class CommonMarkDataProcessor { }); // Use tasklist plugin - let parser = md.use(markdownItTaskLists, {label: true}); + let parser = md.use(markdownItTaskLists, { label: true }); parser.inline.ruler.before('text', 'op_workpackage_ref', workPackageRefInlineRule); + parser.inline.ruler.before( + 'text', + 'op_wiki_page_link', + (state, silent) => wikiPageLinkInlineRule(state, silent, this.editor) + ); const previousRenderer = parser.renderer.rules.code_block; md.renderer.rules.code_block = function (tokens, idx, options, env, self) { @@ -207,7 +249,7 @@ export default class CommonMarkDataProcessor { * @see */ turndownService.addRule('orderedListItems', { - filter: function(node) { + filter: function (node) { if (node.nodeName !== 'LI') { return false; } @@ -215,28 +257,28 @@ export default class CommonMarkDataProcessor { return !!node.closest('ol'); }, replacement: function (content, node, options) { - content = content - .replace(/^\n+/, '') // remove leading newlines - .replace(/\n+$/, '\n'); // replace trailing newlines with just a single one - - var parent = node.parentNode; - var prefix = options.bulletListMarker + ' '; - var number = 1; - if (parent.nodeName === 'OL') { - var start = parent.getAttribute('start'); - var index = Array.prototype.indexOf.call(parent.children, node); - number = start ? Number(start) + index : index + 1; - prefix = number + '. '; - } - - // Calculate indentation based on the width of the number prefix - var indentWidth = prefix.length; - var indent = ' '.repeat(indentWidth); - content = content.replace(/\n/gm, '\n' + indent); - - return ( - prefix + content + (node.nextSibling && !/\n$/.test(content) ? '\n' : '') - ); + content = content + .replace(/^\n+/, '') // remove leading newlines + .replace(/\n+$/, '\n'); // replace trailing newlines with just a single one + + var parent = node.parentNode; + var prefix = options.bulletListMarker + ' '; + var number = 1; + if (parent.nodeName === 'OL') { + var start = parent.getAttribute('start'); + var index = Array.prototype.indexOf.call(parent.children, node); + number = start ? Number(start) + index : index + 1; + prefix = number + '. '; + } + + // Calculate indentation based on the width of the number prefix + var indentWidth = prefix.length; + var indent = ' '.repeat(indentWidth); + content = content.replace(/\n/gm, '\n' + indent); + + return ( + prefix + content + (node.nextSibling && !/\n$/.test(content) ? '\n' : '') + ); } }); @@ -280,10 +322,10 @@ export default class CommonMarkDataProcessor { replacement: function (_content, node) { // Remove filler attribute, but keep empty lines node.querySelectorAll('td br[data-cke-filler]').forEach((node) => { - if (node.nextElementSibling) { - node.removeAttribute('data-cke-filler'); - } - }); + if (node.nextElementSibling) { + node.removeAttribute('data-cke-filler'); + } + }); return node.outerHTML; } @@ -306,6 +348,15 @@ export default class CommonMarkDataProcessor { }, }); + turndownService.addRule('wikiPageLink', { + filter: (node) => node.nodeName === 'TURBO-FRAME' && node.getAttribute('data-type') === 'wiki-page-link', + replacement: (_content, node) => { + const providerId = node.getAttribute('data-provider-id') || ''; + const pageIdentifier = node.getAttribute('data-page-identifier') || ''; + return `\\[\\[\\[${providerId}:${pageIdentifier}\\]\\]\\]`; + }, + }); + turndownService.addRule('openProjectMacros', { filter: ['macro'], replacement: (_content, node) => { diff --git a/src/op-plugins.js b/src/op-plugins.js index 8d7453b..8cf7c69 100644 --- a/src/op-plugins.js +++ b/src/op-plugins.js @@ -43,6 +43,7 @@ import { PageBreak } from '@ckeditor/ckeditor5-page-break'; import { Autosave } from '@ckeditor/ckeditor5-autosave'; import OpContentRevisions from "./plugins/op-content-revisions/op-content-revisions"; import OPMacroWpQuickinfoPlugin from "./plugins/op-macro-wp-quickinfo/op-macro-wp-quickinfo-plugin"; +import OpMacroWikiPageLinkPlugin from "./plugins/op-macro-wiki-page-link/op-macro-wiki-page-link-plugin"; // We divide our plugins into separate concerns here // in order to enable / disable each group by configuration @@ -93,6 +94,7 @@ export const builtinPlugins = [ OPSourceCodePlugin, OpContentRevisions, OPMacroWpQuickinfoPlugin, + OpMacroWikiPageLinkPlugin, CodeBlockPlugin, CommonMark, diff --git a/src/plugins/op-macro-wiki-page-link/op-macro-wiki-page-link-plugin.js b/src/plugins/op-macro-wiki-page-link/op-macro-wiki-page-link-plugin.js new file mode 100644 index 0000000..e200b4b --- /dev/null +++ b/src/plugins/op-macro-wiki-page-link/op-macro-wiki-page-link-plugin.js @@ -0,0 +1,87 @@ +import { Plugin } from "@ckeditor/ckeditor5-core"; +import { toWidget } from "@ckeditor/ckeditor5-widget"; +import { getOPPath } from "../op-context/op-context"; + +const MODEL_ELEMENT_NAME = 'op-macro-wiki-page-link'; + +export default class OpMacroWikiPageLinkPlugin extends Plugin { + static get pluginName() { + return 'OpMacroWikiPageLink'; + } + + init() { + const editor = this.editor; + const conversion = editor.conversion; + + editor.model.schema.register(MODEL_ELEMENT_NAME, { + allowWhere: '$text', + isInline: true, + isObject: true, + allowAttributes: ['providerId', 'pageIdentifier'], + }); + + conversion.for('upcast').elementToElement({ + view: { name: 'turbo-frame', attributes: { 'data-type': 'wiki-page-link' } }, + model: (viewElement, { writer }) => { + const providerId = viewElement.getAttribute('data-provider-id'); + const pageIdentifier = viewElement.getAttribute('data-page-identifier'); + return writer.createElement(MODEL_ELEMENT_NAME, { providerId, pageIdentifier }); + }, + }); + + conversion.for('dataDowncast').elementToElement({ + model: MODEL_ELEMENT_NAME, + view: (modelElement, { writer }) => { + const providerId = modelElement.getAttribute('providerId'); + const pageIdentifier = modelElement.getAttribute('pageIdentifier'); + const frameId = crypto.randomUUID(); + + const container = writer.createContainerElement( + 'turbo-frame', + { + id: frameId, + src: this.macroUrl(providerId, pageIdentifier, frameId), + 'data-provider-id': providerId, + 'data-page-identifier': pageIdentifier, + 'data-type': 'wiki-page-link', + }); + + const ref = `[[[${providerId}:${pageIdentifier}]]]`; + writer.insert(writer.createPositionAt(container, 0), writer.createText(ref)); + return container; + }, + }); + + conversion.for('editingDowncast').elementToElement({ + model: MODEL_ELEMENT_NAME, + view: (modelElement, { writer }) => { + const providerId = modelElement.getAttribute('providerId'); + const pageIdentifier = modelElement.getAttribute('pageIdentifier'); + const frameId = crypto.randomUUID(); + + const wrapper = writer.createContainerElement('span', { + class: 'my-wiki-page-link-widget', + }); + + const raw = writer.createRawElement( + 'turbo-frame', + { + id: frameId, + src: this.macroUrl(providerId, pageIdentifier, frameId), + 'data-provider-id': providerId, + 'data-page-identifier': pageIdentifier, + 'data-type': 'wiki-page-link', + }, + () => { }, + ); + + writer.insert(writer.createPositionAt(wrapper, 0), raw); + return toWidget(wrapper, writer, { label: `[[[${providerId}:${pageIdentifier}]]]` }); + } + }); + } + + macroUrl(providerId, pageIdentifier, frameId) { + return getOPPath(this.editor).wikiPageLinkMacro(providerId, pageIdentifier, frameId) + } +} From 8506bfffb7b04a85994f208a73aec98d7fc01887 Mon Sep 17 00:00:00 2001 From: Eric Schubert Date: Tue, 12 May 2026 15:58:51 +0200 Subject: [PATCH 2/2] [#74710] fix test setup --- tests/commonmark/_utils/utils.js | 15 +++++++++---- tests/commonmark/escaping.test.js | 36 +++++++++++++++---------------- 2 files changed, 28 insertions(+), 23 deletions(-) diff --git a/tests/commonmark/_utils/utils.js b/tests/commonmark/_utils/utils.js index eea7dd5..7accc2a 100644 --- a/tests/commonmark/_utils/utils.js +++ b/tests/commonmark/_utils/utils.js @@ -3,9 +3,10 @@ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ +import { _stringifyView as stringify } from "@ckeditor/ckeditor5-engine"; +import { EditingView, StylesProcessor } from "@ckeditor/ckeditor5-engine"; + import MarkdownDataProcessor from '../../../src/commonmark/commonmarkdataprocessor'; -import {_stringifyView as stringify} from "@ckeditor/ckeditor5-engine"; -import {StylesProcessor, ViewDocument} from "@ckeditor/ckeditor5-engine"; /** * Tests MarkdownDataProcessor. @@ -20,9 +21,9 @@ import {StylesProcessor, ViewDocument} from "@ckeditor/ckeditor5-engine"; * @returns {module:engine/view/documentfragment~DocumentFragment} */ export function testDataProcessor(markdown, viewString, normalizedMarkdown, options) { - const viewDocument = new ViewDocument(new StylesProcessor()); + const editor = createTestEditor(); - const dataProcessor = new MarkdownDataProcessor(viewDocument); + const dataProcessor = new MarkdownDataProcessor(editor); if (options && options.setup) { options.setup(dataProcessor); @@ -46,6 +47,12 @@ export function testDataProcessor(markdown, viewString, normalizedMarkdown, opti return viewFragment; } +export function createTestEditor() { + const view = new EditingView(new StylesProcessor()); + + return { editing: { view } }; +} + function cleanHtml(html) { // Space between table elements. html = html.replace(/(th|td|tr)>\s+<(\/?(?:th|td|tr))/g, '$1><$2'); diff --git a/tests/commonmark/escaping.test.js b/tests/commonmark/escaping.test.js index 0272067..7017005 100644 --- a/tests/commonmark/escaping.test.js +++ b/tests/commonmark/escaping.test.js @@ -4,25 +4,24 @@ */ import MarkdownDataProcessor from '../../src/commonmark/commonmarkdataprocessor'; -import {_stringifyView as stringify} from '@ckeditor/ckeditor5-engine'; -import {testDataProcessor} from './_utils/utils.js'; -import {StylesProcessor, ViewDocument} from "@ckeditor/ckeditor5-engine"; +import { _stringifyView as stringify } from '@ckeditor/ckeditor5-engine'; +import { testDataProcessor, createTestEditor } from './_utils/utils.js'; const testCases = { - 'backslash': {test: '\\\\', result: '\\'}, - 'underscore': {test: '\\_', result: '_'}, - 'left brace': {test: '\\{', result: '{'}, - 'right brace': {test: '\\}', result: '}'}, - 'left bracket': {test: '\\[', result: '['}, - 'right bracket': {test: '\\]', result: ']'}, - 'left paren': {test: '\\(', result: '('}, - 'right paren': {test: '\\)', result: ')'}, - 'greater than': {test: '\\>', result: '>'}, - 'hash': {test: '\\#', result: '#'}, - 'period': {test: '\\.', result: '.'}, - 'exclamation mark': {test: '\\!', result: '!'}, - 'plus': {test: '\\+', result: '+'}, - 'minus': {test: '\\-', result: '-'} + 'backslash': { test: '\\\\', result: '\\' }, + 'underscore': { test: '\\_', result: '_' }, + 'left brace': { test: '\\{', result: '{' }, + 'right brace': { test: '\\}', result: '}' }, + 'left bracket': { test: '\\[', result: '[' }, + 'right bracket': { test: '\\]', result: ']' }, + 'left paren': { test: '\\(', result: '(' }, + 'right paren': { test: '\\)', result: ')' }, + 'greater than': { test: '\\>', result: '>' }, + 'hash': { test: '\\#', result: '#' }, + 'period': { test: '\\.', result: '.' }, + 'exclamation mark': { test: '\\!', result: '!' }, + 'plus': { test: '\\+', result: '+' }, + 'minus': { test: '\\-', result: '-' } }; describe('Commonmark', () => { @@ -31,8 +30,7 @@ describe('Commonmark', () => { let dataProcessor; beforeEach(() => { - const viewDocument = new ViewDocument(new StylesProcessor()); - dataProcessor = new MarkdownDataProcessor(viewDocument); + dataProcessor = new MarkdownDataProcessor(createTestEditor()); }); for (const key in testCases) {