Skip to content

Commit 26f6ced

Browse files
cocomarinemaxelkins
authored andcommitted
Add block text highlighting in instruction panel (#1584)
Closes RaspberryPiFoundation/digital-editor-issues#1690 ## Block-type text highlighting - Used [rpf-markdown-core](https://github.com/RaspberryPiFoundation/rpf-markdown-core) (built by the Code Club team) for rendering instructions, chosen for its inline Kramdown class support. - Refactored the css file so that they apply to both light and dark mode without breaking others. | Before | After | | -------- | ------- | | <img width="392" height="349" alt="Screenshot 2026-08-10 at 17 02 22" src="https://github.com/user-attachments/assets/907a3a46-c871-4128-8f2d-081a9293edcf" /> | <img width="395" height="364" alt="Screenshot 2026-08-10 at 17 04 13" src="https://github.com/user-attachments/assets/8ac0b8f3-9a34-45ae-b134-334f8a98454a" /> | ## Prism and other fixes - While doing this work, I found Prism had stopped working possibly due to the partial Vite migration. Prism provides syntax highlighting, line numbering and line highlighting in code blocks of instructions. Classroom and ExpCS instructions don't use these currently, but CCP instructions do (and CCP team also noticed this when running projects site locally along with latest editor-ui). <img width="360" alt="Screenshot 2026-08-10 at 17 02 57" src="https://github.com/user-attachments/assets/6d134da6-0bb3-476f-8c80-08a3cacb9456" /> - The existing `vite.lib.js` configured `babel-plugin-prismjs` via @vitejs/plugin-react's babel option. But the plugin-react v6 no longer has, so the config was ignored. Replaced with explicit imports in src/utils/prism.js. - In dev environment, `vite-plugin-node-polyfills` prepends a shim banner to every pre-bundled dependency without terminating the last statement. Prism's plugin files start with (function(){…})(), so it was parsed as an argument to the shim and then short-circuited away, resulting in line-numbers and line-highlight not registered. Worked around with optimizeDeps.exclude in vite.config.js. - Jest couldn't load any test importing rpf-markdown-core. Its CJS build requires marked and scratchblocks, both ESM-only with no CommonJS entry. Updated transformIgnorePatterns to exempt both, matched against the full path so the nested rpf-markdown-core/node_modules/scratchblocks copy is also covered. ## Can our users use any other features that the rpf-markdown-core provides? - Not yet. There are some styling issues with others which we need to address first. - CCP team is also open to collaboration if we require certain set of functionalities from or improvements to the library. | Spacing issues | issues with dark mode | | -------- | ------- | | <img width="400" height="580" alt="Screenshot 2026-08-10 at 17 04 34" src="https://github.com/user-attachments/assets/8b5e80ff-5f78-4857-9adc-21d5ee06387c" /> | <img width="400" alt="Screenshot 2026-08-10 at 17 19 52" src="https://github.com/user-attachments/assets/044320f4-70ee-4017-8b4f-4b5c95753e14" /> |
1 parent 91d2d23 commit 26f6ced

15 files changed

Lines changed: 307 additions & 181 deletions

File tree

.babelrc

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,3 @@
11
{
2-
"presets": ["react-app"],
3-
"plugins": [
4-
[
5-
"prismjs",
6-
{
7-
"languages": ["javascript", "css", "python", "html"],
8-
"plugins": [
9-
"line-numbers",
10-
"line-highlight",
11-
"highlight-keywords",
12-
"normalize-whitespace"
13-
],
14-
"theme": "twilight",
15-
"css": true
16-
}
17-
]
18-
]
2+
"presets": ["react-app"]
193
}

jest.config.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,13 @@ module.exports = {
2020
"<rootDir>/node_modules/jest-transform-stub",
2121
},
2222
transformIgnorePatterns: [
23-
"[/\\\\]node_modules[/\\\\](?!three).+\\.(js|jsx|mjs|cjs|ts|tsx)$",
23+
// Ignore everything under node_modules except the packages listed below,
24+
// which Jest has to transform because they are "type": "module" with no
25+
// CommonJS entry point: rpf-markdown-core's CJS build requires marked and
26+
// scratchblocks/index.js, so their raw `export` syntax would otherwise
27+
// reach the CJS loader. A nested copy (rpf-markdown-core/node_modules/
28+
// scratchblocks) is also exempted.
29+
"^(?!.*[/\\\\]node_modules[/\\\\](?:three|marked|scratchblocks)[/\\\\]).*[/\\\\]node_modules[/\\\\].+\\.(js|jsx|mjs|cjs|ts|tsx)$",
2430
"^.+\\.module\\.(css|sass|scss)$",
2531
],
2632
modulePaths: [],

package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"@juggle/resize-observer": "^3.3.1",
1616
"@raspberrypifoundation/design-system-react": "^2.9.2",
1717
"@raspberrypifoundation/python-friendly-error-messages": "0.8.0",
18+
"@raspberrypifoundation/rpf-markdown-core": "^0.1.2",
1819
"@react-three/drei": "^10.0.0",
1920
"@react-three/fiber": "^9.6.1",
2021
"@reduxjs/toolkit": "^1.6.2",
@@ -38,7 +39,6 @@
3839
"js-convert-case": "^4.2.0",
3940
"jszip": "^3.10.1",
4041
"jszip-utils": "^0.1.0",
41-
"marked": "^15.0.6",
4242
"mime-types": "^2.1.35",
4343
"node-html-parser": "^6.1.5",
4444
"oidc-client": "^1.11.5",
@@ -110,7 +110,6 @@
110110
"@vitejs/plugin-react": "^6",
111111
"babel-eslint": "^10.1.0",
112112
"babel-jest": "^29.1.2",
113-
"babel-plugin-prismjs": "^2.1.0",
114113
"babel-preset-react-app": "^10.0.1",
115114
"browserslist": "^4.28.6",
116115
"browserslist-to-esbuild": "^2.1.1",

src/assets/stylesheets/Instructions.scss

Lines changed: 9 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -8,48 +8,14 @@
88
.project-instructions {
99
block-size: 100%;
1010

11-
code.block3control {
12-
background-color: var(--scratch-control-primary);
13-
}
14-
code.block3events {
15-
background-color: var(--scratch-events-primary);
16-
}
17-
code.block3extensions {
18-
background-color: var(--scratch-pen-primary);
19-
}
20-
code.block3looks {
21-
background-color: var(--scratch-looks-primary);
22-
}
23-
code.block3motion {
24-
background-color: var(--scratch-motion-primary);
25-
}
26-
code.block3myblocks {
27-
background-color: var(--scratch-more-primary);
28-
}
29-
code.block3operators {
30-
background-color: var(--scratch-operators-primary);
31-
}
32-
code.block3sensing {
33-
background-color: var(--scratch-sensing-primary);
34-
}
35-
code.block3sound {
36-
background-color: var(--scratch-sounds-primary);
37-
}
38-
code.block3variables {
39-
background-color: var(--scratch-data-primary);
11+
@each $block, $category in $scratch-block-categories {
12+
code.block3#{$block} {
13+
background-color: var(--scratch-#{$category}-primary);
14+
}
4015
}
4116

4217
@media (prefers-contrast: more) {
43-
code.block3control,
44-
code.block3events,
45-
code.block3extensions,
46-
code.block3looks,
47-
code.block3motion,
48-
code.block3myblocks,
49-
code.block3operators,
50-
code.block3sensing,
51-
code.block3sound,
52-
code.block3variables {
18+
code[class*="block3"] {
5319
color: var(--rpf-black);
5420
}
5521
}
@@ -416,10 +382,10 @@
416382
}
417383

418384
.--dark .project-instructions {
419-
pre {
420-
background-color: $rpf-grey-850;
421-
}
422-
code {
385+
// Exclude scratchblocks references, otherwise this would tie on specificity
386+
// with their category colours above and win by coming later in the file.
387+
pre,
388+
code:not([class*="block3"]) {
423389
background-color: $rpf-grey-850;
424390
}
425391
}

src/assets/stylesheets/_scratch_colours.scss

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,21 @@
11
// Scratch block category colours, sourced from
22
// @RaspberryPiFoundation/scratch-gui's src/lib/settings/color-mode/*/index.js
3+
4+
// scratchblocks names its categories differently to scratch-gui, so map the
5+
// `code.block3<key>` class it emits onto the `--scratch-<value>-primary` var.
6+
$scratch-block-categories: (
7+
"control": "control",
8+
"events": "events",
9+
"extensions": "pen",
10+
"looks": "looks",
11+
"motion": "motion",
12+
"myblocks": "more",
13+
"operators": "operators",
14+
"sensing": "sensing",
15+
"sound": "sounds",
16+
"variables": "data",
17+
);
18+
319
.project-instructions {
420
--scratch-motion-primary: #4c97ff;
521
--scratch-looks-primary: #9966ff;

src/components/Menus/Sidebar/InstructionsPanel/InstructionsPanel.test.jsx

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,11 @@ import { setInstructionsEditable } from "../../../../redux/EditorSlice";
44
import { setCurrentStepPosition } from "../../../../redux/InstructionsSlice";
55
import { act } from "react";
66
import Modal from "react-modal";
7-
import Prism from "prismjs";
87
import { scratchblocksInit } from "../../../../utils/scratchblocks";
98
import { renderWithProviders } from "../../../../utils/renderWithProviders";
109

1110
window.HTMLElement.prototype.scrollTo = jest.fn();
12-
jest.mock("prismjs", () => ({
13-
...jest.requireActual("prismjs"),
14-
highlightElement: jest.fn(),
15-
}));
11+
1612
jest.mock("../../../../utils/scratchblocks", () => ({
1713
scratchblocksInit: jest.fn(),
1814
}));
@@ -279,11 +275,6 @@ describe("When instructions are not editable", () => {
279275
test("Renders the progress bar", () => {
280276
expect(screen.queryByRole("progressbar")).toBeInTheDocument();
281277
});
282-
283-
test("Applies syntax highlighting to step content", () => {
284-
const codeElement = document.getElementsByClassName("language-python")[0];
285-
expect(Prism.highlightElement).toHaveBeenCalledWith(codeElement);
286-
});
287278
});
288279

289280
describe("When there is only one step", () => {

src/components/Menus/Sidebar/InstructionsPanel/InstructionsStep/InstructionsStep.jsx

Lines changed: 13 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,13 @@
11
import React, { useEffect, useRef } from "react";
2-
import { marked } from "marked";
3-
import Prism from "prismjs";
2+
import { processEditorProject } from "@raspberrypifoundation/rpf-markdown-core";
3+
import Prism from "../../../../../utils/prism";
44
import { scratchblocksInit } from "../../../../../utils/scratchblocks";
55

6-
const markdownRenderer = new marked.Renderer();
7-
markdownRenderer.link = function (data) {
8-
return `<a href="${data.href}" target="_blank" rel="noreferrer"
9-
}">${data.text}</a>`;
10-
};
11-
marked.setOptions({ renderer: markdownRenderer });
12-
136
const getStepHtml = (step) => {
147
if (step.content !== undefined) {
158
return step.content;
169
}
17-
return marked.parse(step.markdown_content ?? "");
10+
return processEditorProject(step.markdown_content ?? "");
1811
};
1912

2013
const applySyntaxHighlighting = (container) => {
@@ -23,11 +16,15 @@ const applySyntaxHighlighting = (container) => {
2316
);
2417

2518
codeElements.forEach((element) => {
26-
if (window.syntaxHighlight) {
27-
window.syntaxHighlight.highlightElement(element);
28-
} else {
29-
Prism.highlightElement(element);
30-
}
19+
Prism.highlightElement(element);
20+
});
21+
};
22+
23+
const applyExternalLinkAttributes = (container) => {
24+
container.querySelectorAll("a[href]").forEach((link) => {
25+
if (link.getAttribute("href").startsWith("#")) return;
26+
link.setAttribute("target", "_blank");
27+
link.setAttribute("rel", "noreferrer");
3128
});
3229
};
3330

@@ -39,29 +36,13 @@ const InstructionsStep = ({
3936
}) => {
4037
const stepContent = useRef();
4138

42-
useEffect(() => {
43-
Prism.manual = true;
44-
if (Prism.plugins.NormalizeWhitespace) {
45-
Prism.plugins.NormalizeWhitespace.setDefaults({
46-
"remove-indent": false,
47-
"remove-initial-line-feed": true,
48-
"left-trim": false,
49-
});
50-
Prism.hooks.add("before-sanity-check", function (env) {
51-
if (!env.code) return;
52-
53-
// Remove multiple leading blank lines (empty or whitespace-only)
54-
env.code = env.code.replace(/^(?:\s*\n)+/, "");
55-
});
56-
}
57-
}, []);
58-
5939
useEffect(() => {
6040
if (!stepContent.current || !step) return;
6141

6242
stepContent.current.parentElement?.scrollTo({ top: 0 });
6343
stepContent.current.innerHTML = getStepHtml(step);
6444
applySyntaxHighlighting(stepContent.current);
45+
applyExternalLinkAttributes(stepContent.current);
6546

6647
if (isScratchProject) {
6748
scratchblocksInit(language, stepContent.current);

src/components/Menus/Sidebar/InstructionsPanel/InstructionsStep/InstructionsStep.test.jsx

Lines changed: 34 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { render, screen } from "@testing-library/react";
2-
import Prism from "prismjs";
2+
import Prism from "../../../../../utils/prism";
33
import InstructionsStep from "./InstructionsStep";
44
import { scratchblocksInit } from "../../../../../utils/scratchblocks";
55

@@ -47,6 +47,39 @@ describe("When the step has markdown_content", () => {
4747
});
4848
});
4949

50+
describe("When markdown attaches a class to inline code", () => {
51+
const renderMarkdown = (markdown_content) =>
52+
render(<InstructionsStep step={{ markdown_content }} />).container;
53+
54+
const scratchBlockClasses = [
55+
"block3control",
56+
"block3events",
57+
"block3extensions",
58+
"block3looks",
59+
"block3motion",
60+
"block3myblocks",
61+
"block3operators",
62+
"block3sensing",
63+
"block3sound",
64+
"block3variables",
65+
];
66+
67+
test.each(scratchBlockClasses)("Applies %s to the code element", (name) => {
68+
const container = renderMarkdown(`\`Move\`{:class="${name}"}`);
69+
70+
const code = container.querySelector("code");
71+
expect(code).toHaveClass(name);
72+
expect(code).toHaveTextContent("Move");
73+
});
74+
75+
test("Consumes the attribute syntax instead of rendering it", () => {
76+
const container = renderMarkdown('`Motion`{:class="block3motion"}');
77+
78+
expect(container.textContent).toContain("Motion");
79+
expect(container.textContent).not.toContain("{:class");
80+
});
81+
});
82+
5083
describe("When there is no step", () => {
5184
test("Renders without crashing", () => {
5285
const { container } = render(<InstructionsStep step={undefined} />);
@@ -66,23 +99,6 @@ describe("Syntax highlighting", () => {
6699
const codeElement = document.getElementsByClassName("language-python")[0];
67100
expect(Prism.highlightElement).toHaveBeenCalledWith(codeElement);
68101
});
69-
70-
test("Uses window.syntaxHighlight when defined", () => {
71-
window.syntaxHighlight = { highlightElement: vi.fn() };
72-
73-
render(
74-
<InstructionsStep
75-
step={{ content: "<code class='language-python'>print(1)</code>" }}
76-
/>,
77-
);
78-
79-
const codeElement = document.getElementsByClassName("language-python")[0];
80-
expect(window.syntaxHighlight.highlightElement).toHaveBeenCalledWith(
81-
codeElement,
82-
);
83-
84-
delete window.syntaxHighlight;
85-
});
86102
});
87103

88104
describe("When isScratchProject is true", () => {

src/containers/WebComponentLoader.jsx

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -204,24 +204,6 @@ const WebComponentLoader = (props) => {
204204
dispatch(setOfflineEnabled(offlineEnabled));
205205
}, [offlineEnabled, dispatch]);
206206

207-
useEffect(() => {
208-
// Create a script element to save the existing Prism object if there is one
209-
const script = document.createElement("script");
210-
script.textContent = `
211-
if (window.Prism) {
212-
window.syntaxHighlight = window.Prism;
213-
}
214-
`;
215-
216-
// Append the script to the document body
217-
document.body.appendChild(script);
218-
219-
// Clean up the script when the component unmounts
220-
return () => {
221-
document.body.removeChild(script);
222-
};
223-
}, []);
224-
225207
const renderSuccessState = () => (
226208
<>
227209
<SettingsContext.Provider

src/containers/WebComponentLoader.test.jsx

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,6 @@ const user = { access_token: "my_token" };
5757
describe("When initially rendered", () => {
5858
beforeEach(() => {
5959
document.dispatchEvent = jest.fn();
60-
window.Prism = jest.fn();
6160
const mockStore = configureStore([]);
6261
const initialState = {
6362
editor: {
@@ -103,10 +102,6 @@ describe("When initially rendered", () => {
103102
);
104103
});
105104

106-
test("It saves window.Prism to window.syntaxHighlight", () => {
107-
expect(window.syntaxHighlight).toEqual(window.Prism);
108-
});
109-
110105
test("it sets the language in i18n", () => {
111106
expect(mockedChangeLanguage).toHaveBeenCalledWith("es-LA");
112107
});

0 commit comments

Comments
 (0)