Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Development: Upgrade markdown library to markdown-it #9354

Open
wants to merge 23 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions angular.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,7 @@
"react-is",
"rfdc",
"shallowequal",
"showdown-highlight",
"showdown-katex",
"showdown",
"markdown-it-class",
Strohgelaender marked this conversation as resolved.
Show resolved Hide resolved
"smoothscroll-polyfill",
"sockjs-client",
"use-sync-external-store/shim",
Expand Down
221 changes: 125 additions & 96 deletions package-lock.json

Large diffs are not rendered by default.

15 changes: 7 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"@swimlane/ngx-charts": "20.5.0",
"@swimlane/ngx-graph": "8.4.0",
"@vscode/codicons": "0.0.36",
"@vscode/markdown-it-katex": "1.1.0",
"bootstrap": "5.3.3",
"compare-versions": "6.1.1",
"core-js": "3.38.1",
Expand All @@ -57,6 +58,9 @@
"js-video-url-parser": "0.5.1",
"jszip": "3.10.1",
"lodash-es": "4.17.21",
"markdown-it": "14.1.0",
"markdown-it-class": "1.0.0",
"markdown-it-highlightjs": "4.2.0",
"mobile-drag-drop": "3.0.0-rc.0",
"monaco-editor": "0.52.0",
"ngx-infinite-scroll": "18.0.0",
Expand All @@ -65,15 +69,13 @@
"pdfjs-dist": "4.6.82",
"posthog-js": "1.163.0",
"rxjs": "7.8.1",
"showdown": "2.1.0",
"showdown-highlight": "3.1.0",
"showdown-katex": "0.6.0",
"simple-statistics": "7.8.5",
"smoothscroll-polyfill": "0.4.4",
"sockjs-client": "1.6.1",
"split.js": "1.6.5",
"ts-cacheable": "1.0.10",
"tslib": "2.7.0",
"turndown": "7.2.0",
"uuid": "10.0.0",
"webstomp-client": "1.2.6",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
Expand All @@ -100,13 +102,9 @@
"@typescript-eslint/eslint-plugin": "^8.6.0"
},
"jsdom": "25.0.0",
"katex": "0.16.11",
"postcss": "8.4.47",
"rimraf": "6.0.1",
"semver": "7.6.3",
"showdown-katex": {
"showdown": "2.1.0"
},
"tough-cookie": "5.0.0",
"vite": "5.4.6",
"webpack-dev-middleware": "7.4.2",
Expand All @@ -131,11 +129,12 @@
"@types/dompurify": "3.0.5",
"@types/jest": "29.5.13",
"@types/lodash-es": "4.17.12",
"@types/markdown-it": "14.1.2",
"@types/node": "22.5.5",
"@types/papaparse": "5.3.14",
"@types/showdown": "2.0.6",
"@types/smoothscroll-polyfill": "0.3.4",
"@types/sockjs-client": "1.5.4",
"@types/turndown": "5.0.5",
"@types/uuid": "10.0.0",
"@typescript-eslint/eslint-plugin": "8.6.0",
"@typescript-eslint/parser": "8.6.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,19 @@
import { Injectable } from '@angular/core';
import { ProgrammingExerciseTestCase } from 'app/entities/programming/programming-exercise-test-case.model';
import { ArtemisTextReplacementExtension } from 'app/shared/markdown-editor/extensions/ArtemisTextReplacementExtension';
import { escapeStringForUseInRegex } from 'app/shared/util/global.utils';
import { Subject } from 'rxjs';
import { tap } from 'rxjs/operators';
import { escapeStringForUseInRegex } from 'app/shared/util/global.utils';
import { ProgrammingExerciseInstructionService, TestCaseState } from 'app/exercises/programming/shared/instructions-render/service/programming-exercise-instruction.service';
import { ProgrammingExercisePlantUmlService } from 'app/exercises/programming/shared/instructions-render/service/programming-exercise-plant-uml.service';
import { ArtemisShowdownExtensionWrapper } from 'app/shared/markdown-editor/extensions/artemis-showdown-extension-wrapper';
import { Result } from 'app/entities/result.model';
import { ShowdownExtension } from 'showdown';
import DOMPurify from 'dompurify';

// This regex is the same as in the server: ProgrammingExerciseTaskService.java
const testsColorRegex = /testsColor\((\s*[^()\s]+(\([^()]*\))?)\)/g;

@Injectable({ providedIn: 'root' })
export class ProgrammingExercisePlantUmlExtensionWrapper implements ArtemisShowdownExtensionWrapper {
export class ProgrammingExercisePlantUmlExtensionWrapper extends ArtemisTextReplacementExtension {
private latestResult?: Result;
private testCases?: ProgrammingExerciseTestCase[];
private injectableElementsFoundSubject = new Subject<() => void>();
Expand All @@ -25,7 +24,9 @@ export class ProgrammingExercisePlantUmlExtensionWrapper implements ArtemisShowd
constructor(
private programmingExerciseInstructionService: ProgrammingExerciseInstructionService,
private plantUmlService: ProgrammingExercisePlantUmlService,
) {}
) {
super();
}

/**
* Sets latest result according to parameter.
Expand Down Expand Up @@ -67,7 +68,6 @@ export class ProgrammingExercisePlantUmlExtensionWrapper implements ArtemisShowd
}

/**
* Creates and returns an extension to current exercise.
* The extension provides a custom rendering mechanism for embedded plantUml diagrams.
* The mechanism works as follows:
* 1) Find (multiple) embedded plantUml diagrams based on a regex (startuml, enduml).
Expand All @@ -76,55 +76,49 @@ export class ProgrammingExercisePlantUmlExtensionWrapper implements ArtemisShowd
* 4) Send the plantUml content to the server for rendering a svg (the result will be cached for performance reasons)
* 5) Inject the computed svg for the plantUml (from the server) into the plantUml div container based on the unique placeholder id (see step 2)
*/
getExtension() {
const extension: ShowdownExtension = {
type: 'lang',
filter: (text: string) => {
const idPlaceholder = '%idPlaceholder%';
// E.g. [task][Implement BubbleSort](testBubbleSort)
const plantUmlRegex = /@startuml([^@]*)@enduml/g;
// E.g. Implement BubbleSort, testBubbleSort
const plantUmlContainer = `<div class="mb-4" id="plantUml-${idPlaceholder}"></div>`;
// Replace test status markers.
const plantUmls = text.match(plantUmlRegex) ?? [];
// Assign unique ids to uml data structure at the beginning.
const plantUmlsIndexed = plantUmls.map((plantUml) => {
const nextIndex = this.plantUmlIndex;
// increase the global unique index so that the next plantUml gets a unique global id
this.plantUmlIndex++;
return { plantUmlId: nextIndex, plantUml };
});
// custom markdown to html rendering: replace the plantUml in the markdown with a simple <div></div> container with a unique id placeholder
// with the global unique id so that we can find the plantUml later on, when it was rendered, and then inject the 'actual' inner html (actually a svg image)
const replacedText = plantUmlsIndexed.reduce((acc: string, umlIndexed: { plantUmlId: number; plantUml: string }): string => {
return acc.replace(new RegExp(escapeStringForUseInRegex(umlIndexed.plantUml), 'g'), plantUmlContainer.replace(idPlaceholder, umlIndexed.plantUmlId.toString()));
}, text);
// before we send the plantUml to the server for rendering, we need to inject the current test status so that the colors can be adapted
// (green == implemented, red == not yet implemented, grey == unknown)
const plantUmlsValidated = plantUmlsIndexed.map((plantUmlIndexed: { plantUmlId: number; plantUml: string }) => {
plantUmlIndexed.plantUml = plantUmlIndexed.plantUml.replace(testsColorRegex, (match: any, capture: string) => {
const tests = this.programmingExerciseInstructionService.convertTestListToIds(capture, this.testCases);
const { testCaseState } = this.programmingExerciseInstructionService.testStatusForTask(tests, this.latestResult);
switch (testCaseState) {
case TestCaseState.SUCCESS:
return 'green';
case TestCaseState.FAIL:
return 'red';
default:
return 'grey';
}
});
return plantUmlIndexed;
});
// send the adapted plantUml to the server for rendering and inject the result into the html DOM based on the unique plantUml id
this.injectableElementsFoundSubject.next(() => {
plantUmlsValidated.forEach((plantUmlIndexed: { plantUmlId: number; plantUml: string }) => {
this.loadAndInjectPlantUml(plantUmlIndexed.plantUml, plantUmlIndexed.plantUmlId);
});
});
return replacedText;
},
};
return extension;
replaceText(text: string): string {
const idPlaceholder = '%idPlaceholder%';
// E.g. [task][Implement BubbleSort](testBubbleSort)
const plantUmlRegex = /@startuml([^@]*)@enduml/g;
Strohgelaender marked this conversation as resolved.
Show resolved Hide resolved
// E.g. Implement BubbleSort, testBubbleSort
const plantUmlContainer = `<div class="mb-4" id="plantUml-${idPlaceholder}"></div>`;
// Replace test status markers.
const plantUmls = text.match(plantUmlRegex) ?? [];
// Assign unique ids to uml data structure at the beginning.
const plantUmlsIndexed = plantUmls.map((plantUml) => {
const nextIndex = this.plantUmlIndex;
// increase the global unique index so that the next plantUml gets a unique global id
this.plantUmlIndex++;
return { plantUmlId: nextIndex, plantUml };
});
// custom markdown to html rendering: replace the plantUml in the markdown with a simple <div></div> container with a unique id placeholder
// with the global unique id so that we can find the plantUml later on, when it was rendered, and then inject the 'actual' inner html (actually a svg image)
const replacedText = plantUmlsIndexed.reduce((acc: string, umlIndexed: { plantUmlId: number; plantUml: string }): string => {
return acc.replace(new RegExp(escapeStringForUseInRegex(umlIndexed.plantUml), 'g'), plantUmlContainer.replace(idPlaceholder, umlIndexed.plantUmlId.toString()));
}, text);
// before we send the plantUml to the server for rendering, we need to inject the current test status so that the colors can be adapted
// (green == implemented, red == not yet implemented, grey == unknown)
const plantUmlsValidated = plantUmlsIndexed.map((plantUmlIndexed: { plantUmlId: number; plantUml: string }) => {
plantUmlIndexed.plantUml = plantUmlIndexed.plantUml.replace(testsColorRegex, (match: any, capture: string) => {
Strohgelaender marked this conversation as resolved.
Show resolved Hide resolved
const tests = this.programmingExerciseInstructionService.convertTestListToIds(capture, this.testCases);
const { testCaseState } = this.programmingExerciseInstructionService.testStatusForTask(tests, this.latestResult);
switch (testCaseState) {
case TestCaseState.SUCCESS:
return 'green';
case TestCaseState.FAIL:
return 'red';
default:
return 'grey';
}
});
return plantUmlIndexed;
});
// send the adapted plantUml to the server for rendering and inject the result into the html DOM based on the unique plantUml id
this.injectableElementsFoundSubject.next(() => {
plantUmlsValidated.forEach((plantUmlIndexed: { plantUmlId: number; plantUml: string }) => {
this.loadAndInjectPlantUml(plantUmlIndexed.plantUml, plantUmlIndexed.plantUmlId);
});
});
Strohgelaender marked this conversation as resolved.
Show resolved Hide resolved
return replacedText;
Strohgelaender marked this conversation as resolved.
Show resolved Hide resolved
Strohgelaender marked this conversation as resolved.
Show resolved Hide resolved
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { Injectable, ViewContainerRef } from '@angular/core';
import { TaskArrayWithExercise } from 'app/exercises/programming/shared/instructions-render/task/programming-exercise-task.model';
import { ArtemisShowdownExtensionWrapper } from 'app/shared/markdown-editor/extensions/artemis-showdown-extension-wrapper';
import { ArtemisTextReplacementExtension } from 'app/shared/markdown-editor/extensions/ArtemisTextReplacementExtension';
import { Observable, Subject } from 'rxjs';
import { ShowdownExtension } from 'showdown';

/**
* Regular expression for finding tasks.
Expand All @@ -18,15 +17,12 @@ import { ShowdownExtension } from 'showdown';
export const taskRegex = /\[task]\[([^[\]]+)]\(((?:[^(),]+(?:\([^()]*\)[^(),]*)?(?:,[^(),]+(?:\([^()]*\)[^(),]*)?)*)?)\)/g;

@Injectable({ providedIn: 'root' })
export class ProgrammingExerciseTaskExtensionWrapper implements ArtemisShowdownExtensionWrapper {
export class ProgrammingExerciseTaskExtensionWrapper extends ArtemisTextReplacementExtension {
// We don't have a provider for ViewContainerRef, so we pass it from ProgrammingExerciseInstructionComponent
viewContainerRef: ViewContainerRef;

private testsForTaskSubject = new Subject<TaskArrayWithExercise>();
private injectableElementsFoundSubject = new Subject<() => void>();

constructor() {}

/**
* Subscribes to injectableElementsFoundSubject.
*/
Expand All @@ -35,23 +31,12 @@ export class ProgrammingExerciseTaskExtensionWrapper implements ArtemisShowdownE
}

/**
* Creates and returns an extension to current exercise.
* The task regex is coupled to the value used in ProgrammingExerciseTaskService in the server and
* `TaskCommand` in the client
* The task regex is coupled to the value used in ProgrammingExerciseTaskService in the server
* and `TaskCommand` in the client
* If you change the regex, make sure to change it in all places!
*/
getExtension() {
const extension: ShowdownExtension = {
type: 'lang',
filter: (problemStatement: string) => {
return this.createTasks(problemStatement);
},
};
return extension;
}

public createTasks(problemStatement: string): string {
return problemStatement.replace(taskRegex, (match) => {
replaceText(text: string): string {
return text.replace(taskRegex, (match) => {
return this.escapeTaskSpecialCharactersForMarkdown(match);
});
}
Strohgelaender marked this conversation as resolved.
Show resolved Hide resolved
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { ThemeService } from 'app/core/theme/theme.service';
import { ProgrammingExerciseTestCase } from 'app/entities/programming/programming-exercise-test-case.model';
import { ProgrammingExerciseGradingService } from 'app/exercises/programming/manage/services/programming-exercise-grading.service';
import { ShowdownExtension } from 'showdown';
import type { PluginSimple } from 'markdown-it';
import { catchError, filter, map, mergeMap, switchMap, tap } from 'rxjs/operators';
import { Observable, Subscription, merge, of } from 'rxjs';
import { ProgrammingExercise } from 'app/entities/programming/programming-exercise.model';
Expand Down Expand Up @@ -80,7 +80,7 @@ export class ProgrammingExerciseInstructionComponent implements OnChanges, OnDes
public renderedMarkdown: SafeHtml;
private injectableContentForMarkdownCallbacks: Array<() => void> = [];

markdownExtensions: ShowdownExtension[];
markdownExtensions: PluginSimple[];
private injectableContentFoundSubscription: Subscription;
private tasksSubscription: Subscription;
private generateHtmlSubscription: Subscription;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,7 @@ export class ShortAnswerQuestionUtil {
if (firstWord === '') {
continue;
}

const firstWordIndex = element.indexOf(firstWord);
const whitespace = '&nbsp;'.repeat(this.getIndentation(originalTextParts[i][0]).length);
formattedTextParts[i][0] = [element.substring(0, firstWordIndex), whitespace, element.substring(firstWordIndex).trim()].join('');
Expand Down
9 changes: 2 additions & 7 deletions src/main/webapp/app/index.d.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,4 @@
declare module 'showdown-katex' {
const main: () => ShowDownExtension;
export = main;
}

declare module 'showdown-highlight' {
const main: ({ pre: boolean }) => ShowDownExtension;
declare module 'markdown-it-class' {
const main: (md: MarkdownIt) => void;
export = main;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type MarkdownIt from 'markdown-it';
import type { PluginSimple } from 'markdown-it';

/**
* Markdown-It extension that allows replacing text in the raw markdown before tokenizing.
*/
export abstract class ArtemisTextReplacementExtension {
getExtension(): PluginSimple {
return (md: MarkdownIt): void => {
// Override the `render` method to process the raw Markdown text before tokenizing
const originalRender = md.render.bind(md);
md.render = (markdownText: string, ...args) => {
// Perform the replacement on the raw markdown text
const modifiedText = this.replaceText(markdownText);
// Call the original render method with the modified text
return originalRender(modifiedText, ...args);
};
};
}

abstract replaceText(text: string): string;
}

This file was deleted.

10 changes: 5 additions & 5 deletions src/main/webapp/app/shared/markdown.service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Injectable } from '@angular/core';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { addCSSClass, htmlForMarkdown } from 'app/shared/util/markdown.conversion.util';
import showdown from 'showdown';
import { htmlForMarkdown } from 'app/shared/util/markdown.conversion.util';
import type { PluginSimple } from 'markdown-it';

@Injectable({ providedIn: 'root' })
export class ArtemisMarkdownService {
Expand All @@ -11,21 +11,21 @@ export class ArtemisMarkdownService {
* Converts markdown into html, sanitizes it and then declares it as safe to bypass further security.
*
* @param {string} markdownText the original markdown text
* @param extensions to use for markdown parsing
* @param {PluginSimple[]} extensions to use for markdown parsing
Strohgelaender marked this conversation as resolved.
Show resolved Hide resolved
* @param {string[]} allowedHtmlTags to allow during sanitization
* @param {string[]} allowedHtmlAttributes to allow during sanitization
* @returns {string} the resulting html as a SafeHtml object that can be inserted into the angular template
*/
safeHtmlForMarkdown(
markdownText?: string,
extensions: showdown.ShowdownExtension[] = [],
extensions: PluginSimple[] = [],
Strohgelaender marked this conversation as resolved.
Show resolved Hide resolved
allowedHtmlTags: string[] | undefined = undefined,
allowedHtmlAttributes: string[] | undefined = undefined,
): SafeHtml {
if (!markdownText || markdownText === '') {
return '';
}
const convertedString = htmlForMarkdown(markdownText, [...extensions, ...addCSSClass], allowedHtmlTags, allowedHtmlAttributes);
const convertedString = htmlForMarkdown(markdownText, extensions, allowedHtmlTags, allowedHtmlAttributes);
return this.sanitizer.bypassSecurityTrustHtml(convertedString);
}

Expand Down
Loading
Loading