Skip to content

[Renderer] Refactor how the markdown renderer works with paragraphs and widgets to be semantically correct#3875

Open
mark-fitzgerald wants to merge 7 commits into
mainfrom
LEMS-4282-refactor-paragraph-and-widget-dom-rendering
Open

[Renderer] Refactor how the markdown renderer works with paragraphs and widgets to be semantically correct#3875
mark-fitzgerald wants to merge 7 commits into
mainfrom
LEMS-4282-refactor-paragraph-and-widget-dom-rendering

Conversation

@mark-fitzgerald

Copy link
Copy Markdown
Contributor

Summary:

The markdown renderer previously wrapped content in nested <div class="paragraph"> elements, producing non-semantic HTML that made styling difficult to scope and some bugs difficult to fix. This PR removes the outer <div class="paragraph"> element and overrides the paragraph rendering rule to emit a real <p> element. It also adds a widgetBlock rendering rule so that block-level widgets are parsed at the block level instead of inside "paragraphs", and splits out block widgets that end up inside a paragraph when content is missing the blank lines that normally separate them. Inline widgets (definition, expression, numeric-input, etc.) still render inside the paragraph, while "special" inline widgets like explanation are rendered without a <p> wrapper. The result is a simpler, semantically correct DOM that's easier to style in a focused, scoped way.

Issue: LEMS-4282

Test plan:

  1. Launch Storybook (pnpm storybook) and view a few renderer stories that mix text, inline widgets (numeric-input, expression), and block widgets (radio).
  2. Inspect the rendered DOM and confirm paragraphs render as <p> elements, block-level widgets are siblings of paragraphs (not nested inside <p>), and explanation widgets render without a <p> wrapper.
  3. Confirm content authored without blank lines around a block widget still renders valid HTML (block widget broken out of the paragraph).

…stion-paragraph handler that only includes the JIPT attributes and containing <div> when the JIPT information is available.

Override the "paragraph" rule so that it renders a containing <p> element instead of a <div class="paragraph">.
When rendering the paragraph, verify that it doesn't have any block-type widgets within it, otherwise, just render the child nodes without a container.
…getBlock rule instead of evaluating paragraph content.
…'main' into LEMS-4282-refactor-paragraph-and-widget-dom-rendering
…break out non-inline widgets out of paragraph containers.
… "special" widgets that are inline, but can't be in a paragraph element. These are rendered without a container.

Refactor the code that handles splitting out block-level widgets within apparent inline containers.
…t): [Renderer] Refactor how the markdown renderer works with paragraphs and widgets to be semantically correct
@mark-fitzgerald mark-fitzgerald self-assigned this Jul 8, 2026
@mark-fitzgerald mark-fitzgerald requested a review from a team July 8, 2026 18:51
@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Size Change: +822 B (+0.16%)

Total Size: 518 kB

📦 View Changed
Filename Size Change
packages/perseus/dist/es/index.js 205 kB +822 B (+0.4%)
ℹ️ View Unchanged
Filename Size
packages/kas/dist/es/index.js 20.6 kB
packages/keypad-context/dist/es/index.js 1 kB
packages/kmath/dist/es/index.js 6.31 kB
packages/math-input/dist/es/index.js 98.5 kB
packages/math-input/dist/es/strings.js 1.61 kB
packages/perseus-core/dist/es/index.item-splitting.js 12.9 kB
packages/perseus-core/dist/es/index.js 27.7 kB
packages/perseus-editor/dist/es/index.js 104 kB
packages/perseus-linter/dist/es/index.js 9.79 kB
packages/perseus-score/dist/es/index.js 9.86 kB
packages/perseus-utils/dist/es/index.js 403 B
packages/perseus/dist/es/strings.js 12.4 kB
packages/pure-markdown/dist/es/index.js 1.39 kB
packages/simple-markdown/dist/es/index.js 6.71 kB

compressed-size-action

…ed export designations in the util file.

Remove files for local dev use.
Comment on lines +1477 to +1495
describe("block-level widgets in paragraphs (malformed markdown)", () => {
// Content authors sometimes forget to separate a block-level widget
// reference with blank lines (\n\n). The single \n before "[[☃ radio
// 1]]" here causes the radio widget to parse as an inline child of the
// paragraph. The renderer must split it out so the block widget is not
// nested inside a <p> (which would be invalid HTML).
const malformedContent =
"**Which picture shows how to measure the pink square?** \n" +
"[[☃ radio 1]]\n" +
"**The pink square is** [[☃ input-number 2]] ** blue squares tall.**";

const malformedQuestion: PerseusRenderer = {
content: malformedContent,
images: {},
widgets: {
"radio 1": generateRadioWidget(),
"input-number 2": generateInputNumberWidget(),
},
};

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This case isn't really malformed markdown. Paragraphs in markdown are allowed to contain line breaks. describe("block-level widgets in paragraphs" seems like an adequate description.

Comment on lines +119 to +121
export const SPECIAL_WIDGET_TYPES: ReadonlySet<string> = new Set([
"explanation",
]);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need a better term for this than "special".

Since I suspect we don't want any more "special" widgets in the future, I propose removing the abstraction and just talking about explanation widgets as the special case.

const isExplanationWidgetNode = (node: any): boolean =>
    node?.type === "widget" && node.widgetType === "explanation";

export const contentHasExplanationWidget = (node: any): boolean =>
    Array.isArray(node.content) && node.content.some(isExplanationWidgetNode);

Array.isArray(node.content) && node.content.some(isSpecialWidgetNode);

const isWhitespaceOnlyTextNode = (node: any): boolean =>
node?.type === "text" && !/\S/.test(node.content);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The regex match is equivalent to

Suggested change
node?.type === "text" && !/\S/.test(node.content);
node?.type === "text" && node.content.trim() === ""

which is probably more recognizable.

Comment on lines +170 to +188
widgetBlock: {
// Process block-level widgets before paragraphs.
order: SimpleMarkdown.defaultRules.paragraph.order - 0.5,
// Match to the widget rule, but at the block level.
match: SimpleMarkdown.blockRegex(rWidgetRule),
// Type this as a "widget" so that the renderer will use
// the same rendering logic as inline widgets.
parse: (capture: any, parse: any, state: any): any => ({
type: "widget",
id: capture[1],
widgetType: capture[2],
}),
react: (node, output, state) => {
// The actual output is handled in the renderer, where
// we know the current widget props/state. This is
// just a stub for testing.
return <em key={state.key}>{`[Widget: ${node.id}]`}</em>;
},
},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we have splitBlockWidgetsFromParagraphs, can this widgetBlock rule be removed now?

type ContentNodes = {blockNodes: Array<any>; inlineNodes: Array<any>};
// Break the paragraph node into as many nodes as needed so that any
// block-level widgets are in their own node.
const {blockNodes, inlineNodes} = node.content.reduce(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think a for loop would be easier to read than reduce here — especially since we're mutating the blockNodes and inlineNodes arrays anyway.

const blockNodes = [];
const inlineNodes = [];
for (const childNode of node.content) {
    // ...
}

return mergeInlineNodes(inlineNodes, blockNodes, node);
});
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These new functions should have tests.

inlineNodes: Array<any>,
blockNodes: Array<any>,
referenceNode: any,
): any | undefined => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
): any | undefined => {
): any => {

blockNodes.push({...referenceNode, content: trimmedContentNodes});
}
return blockNodes;
};

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It feels weird that this function pushes its output into blockNodes — mutating its input in addition to returning the result. That makes the calling code hard to understand because it's not clear when looking at the callsite that blockNodes is going to be mutated.

I might inline mergeInlineNodes; then the first callsite would change to:

contentNodes.blockNodes.push({
    ...node,
    content: trimEdgeWhitespaceNodes(inlineNodes),
});

Comment on lines +18 to +19
return this.props.translationIndex !== undefined &&
this.props.translationIndex !== null ? (

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return this.props.translationIndex !== undefined &&
this.props.translationIndex !== null ? (
return this.props.translationIndex != null ? (

We could also avoid the long ternary with an early return:

if (this.props.translationIndex == null) {
  return this.props.children;
}

// For perseus-article just-in-place-translation (jipt), we need
// to attach some metadata to top-level QuestionParagraphs:
return (
  <div ...>
    {this.props.children}
  </div>
);


const rWidgetParts = new RegExp(rWidgetRule.source + "$");
const snowman = "\u2603";

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NOTE: I have unit tests coming for the changes added to this file.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants