Skip to content

Commit

Permalink
Development: Upgrade markdown library to markdown-it (#9354)
Browse files Browse the repository at this point in the history
  • Loading branch information
Strohgelaender authored Oct 27, 2024
1 parent 6e1c562 commit 432efc3
Show file tree
Hide file tree
Showing 20 changed files with 322 additions and 263 deletions.
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",
"smoothscroll-polyfill",
"sockjs-client",
"use-sync-external-store/shim",
Expand Down
10 changes: 5 additions & 5 deletions docs/user/markdown-support.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Markdown Support

`Markdown <https://daringfireball.net/projects/markdown/>`__ is an easy-to-read, easy-to-write syntax for formatting plain text.

A markdown playground can be found `here <http://demo.showdownjs.com/>`__.
A markdown playground can be found `here <https://markdown-it.github.io/>`__.

Artemis extends the basic `Markdown <https://daringfireball.net/projects/markdown/>`__ syntax to support Artemis-specific features. This Artemis flavored Markdown is used to format text content across the platform using an integrated markdown editor.

Expand Down Expand Up @@ -52,9 +52,9 @@ Markdown is also supported in the context of :ref:`communicating<communication>`
Supported Syntax
^^^^^^^^^^^^^^^^

The integrated markdown editor uses `Showdown <https://github.com/showdownjs/showdown>`__. A quick description of the supported syntax can be found `here <https://github.com/showdownjs/showdown/wiki/Showdown's-Markdown-syntax>`__.
The integrated markdown editor uses `MarkdownIt <https://github.com/markdown-it/markdown-it>`__. A quick description of the supported syntax can be found `here <https://www.markdownguide.org/basic-syntax/>`__.

The following Showdown extensions are activated:
The following Plugins are activated:

- `Showdown Katex <https://obedm503.github.io/showdown-katex>`__ to render LaTeX math and AsciiMath using KaTeX.
- `Showdown Highlight <https://github.com/Bloggify/showdown-highlight>`__ for syntax highlighting in code blocks.
- `MarkdownIt Katex <https://github.com/microsoft/vscode-markdown-it-katex>`__ to render LaTeX math and AsciiMath using KaTeX.
- `MarkdownIt HighlightJS <https://github.com/valeriangalliat/markdown-it-highlightjs>`__ for syntax highlighting in code blocks.
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.7.76",
"posthog-js": "1.176.0",
"rxjs": "7.8.1",
"showdown": "2.1.0",
"showdown-highlight": "3.1.0",
"showdown-katex": "0.6.0",
"simple-statistics": "7.8.7",
"smoothscroll-polyfill": "0.4.4",
"sockjs-client": "1.6.1",
"split.js": "1.6.5",
"ts-cacheable": "1.0.10",
"tslib": "2.8.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 Down Expand Up @@ -102,13 +104,9 @@
},
"express": "5.0.1",
"jsdom": "25.0.1",
"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.10",
"webpack-dev-middleware": "7.4.2",
Expand All @@ -134,11 +132,12 @@
"@types/dompurify": "3.0.5",
"@types/jest": "29.5.14",
"@types/lodash-es": "4.17.12",
"@types/markdown-it": "14.1.2",
"@types/node": "22.7.9",
"@types/papaparse": "5.3.15",
"@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.11.0",
"@typescript-eslint/parser": "8.11.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 { ArtemisTextReplacementPlugin } from 'app/shared/markdown-editor/extensions/ArtemisTextReplacementPlugin';
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 ArtemisTextReplacementPlugin {
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;
// 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: string, 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;
}
}
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 { ArtemisTextReplacementPlugin } from 'app/shared/markdown-editor/extensions/ArtemisTextReplacementPlugin';
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 ArtemisTextReplacementPlugin {
// 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);
});
}
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,24 @@
import type MarkdownIt from 'markdown-it';
import type { PluginSimple } from 'markdown-it';

/**
* Markdown-It plugin that allows replacing text in the raw markdown before tokenizing.
* See more about Markdown-It plugins here: https://github.com/markdown-it/markdown-it/tree/master/docs
*/
export abstract class ArtemisTextReplacementPlugin {
getExtension(): PluginSimple {
return (md: MarkdownIt): void => {
md.core.ruler.before('normalize', 'artemis_text_replacement', (state) => {
// Perform the replacement on the raw markdown text
state.src = this.replaceText(state.src);
});
};
}

/**
* Performs text replacement on the raw markdown before parsing.
* @param text The raw markdown text.
* @returns The modified markdown text after replacements.
*/
abstract replaceText(text: string): string;
}

This file was deleted.

Loading

0 comments on commit 432efc3

Please sign in to comment.