@eeacms/volto-cca-policy 1.0.25 → 1.0.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. package/CHANGELOG.md +20 -13
  2. package/package.json +1 -1
  3. package/src/components/Search/NavigatorCatalogue/NavigatorCatalogueCardItem.jsx +16 -24
  4. package/src/components/Search/NavigatorCatalogue/NavigatorCatalogueCardItem.test.jsx +21 -2
  5. package/src/components/Search/NavigatorGuide/NavigatorGuideContentView.jsx +153 -135
  6. package/src/components/Search/NavigatorGuide/NavigatorGuideContentView.test.jsx +108 -2
  7. package/src/components/Search/NavigatorGuide/utils.js +15 -0
  8. package/src/components/Search/NavigatorGuide/utils.test.js +18 -1
  9. package/src/components/index.js +2 -0
  10. package/src/components/manage/Blocks/ChatbotCatalogue/astUtils.js +129 -3
  11. package/src/components/manage/Blocks/ChatbotCatalogue/astUtils.test.js +207 -0
  12. package/src/components/manage/Blocks/ChatbotCatalogue/docCards.js +8 -0
  13. package/src/components/manage/Blocks/ChatbotCatalogue/docCards.test.js +72 -0
  14. package/src/components/manage/Blocks/ChatbotCatalogue/nextSteps.js +4 -0
  15. package/src/components/manage/Blocks/ChatbotCatalogue/nextSteps.test.js +48 -0
  16. package/src/components/theme/CompareTools/CompareToolsPanel.jsx +1 -1
  17. package/src/components/theme/CompareTools/CompareToolsView.jsx +1 -1
  18. package/src/components/theme/Content/Sections/MetadataItemList.jsx +7 -24
  19. package/src/components/theme/TagOverflowPopup.jsx +28 -0
  20. package/src/components/theme/Views/RelatedTools.jsx +7 -25
  21. package/src/search/navigator_guide/facets.js +1 -0
  22. package/src/search/navigator_guide/guideSteps.js +1 -1
  23. package/theme/globals/navigator.less +11 -1
@@ -1,6 +1,6 @@
1
1
  import React from 'react';
2
2
  import '@testing-library/jest-dom';
3
- import { fireEvent, render } from '@testing-library/react';
3
+ import { fireEvent, render, screen } from '@testing-library/react';
4
4
  import { IntlProvider } from 'react-intl';
5
5
  import { useAtom } from 'jotai';
6
6
  import { useSelector } from 'react-redux';
@@ -21,13 +21,14 @@ jest.mock('./useGuideFacetOptions', () => ({
21
21
  default: jest.fn(),
22
22
  }));
23
23
 
24
- const renderGuide = (result) => {
24
+ const renderGuide = (result, searchContext = {}) => {
25
25
  useSearchContext.mockReturnValue({
26
26
  filters: [{ field: 'cca_adaptation_sectors.keyword', values: ['Water'] }],
27
27
  facets: {},
28
28
  results: [result],
29
29
  totalResults: 1,
30
30
  isLoading: false,
31
+ ...searchContext,
31
32
  });
32
33
  return render(
33
34
  <IntlProvider locale="en">
@@ -92,3 +93,108 @@ describe('NavigatorGuideContentView thumbnails', () => {
92
93
  expect(thumbnail.querySelector('.ri-stack-line')).toBeInTheDocument();
93
94
  });
94
95
  });
96
+
97
+ describe('NavigatorGuideContentView adaptation steps', () => {
98
+ beforeEach(() => {
99
+ jest.clearAllMocks();
100
+ useAtom.mockReturnValue([2, jest.fn()]);
101
+ useSelector.mockReturnValue('en');
102
+ useHistory.mockReturnValue({ push: jest.fn() });
103
+ useGuideFacetOptions.mockReturnValue({
104
+ 'cca_adaptation_support_cycle_step.keyword': [
105
+ 'Step 2: Assess',
106
+ 'Step 1: Prepare',
107
+ ],
108
+ });
109
+ });
110
+
111
+ it('orders steps and requires every selected step', () => {
112
+ const addFilter = jest.fn();
113
+ renderGuide(
114
+ { title: 'Guide tool', href: '/tools/guide-tool' },
115
+ {
116
+ filters: [],
117
+ addFilter,
118
+ facets: {
119
+ 'cca_adaptation_support_cycle_step.keyword': [
120
+ {
121
+ data: [
122
+ { value: 'Step 2: Assess', count: 9 },
123
+ { value: 'Step 1: Prepare', count: 1 },
124
+ ],
125
+ },
126
+ ],
127
+ },
128
+ },
129
+ );
130
+
131
+ const labels = screen.getAllByText(/^Step [12]:/);
132
+ expect(labels.map((label) => label.textContent)).toEqual([
133
+ 'Step 1: Prepare',
134
+ 'Step 2: Assess',
135
+ ]);
136
+ fireEvent.click(screen.getByText('Step 2: Assess'));
137
+ expect(addFilter).toHaveBeenCalledWith(
138
+ 'cca_adaptation_support_cycle_step.keyword',
139
+ 'Step 2: Assess',
140
+ 'all',
141
+ );
142
+ });
143
+
144
+ it('does not show an unrelated selected step on a preview tool', () => {
145
+ const { container } = renderGuide(
146
+ {
147
+ title: 'Guide tool',
148
+ href: '/tools/guide-tool',
149
+ cca_adaptation_support_cycle_step: { raw: ['Step 1: Prepare'] },
150
+ },
151
+ {
152
+ filters: [
153
+ {
154
+ field: 'cca_adaptation_support_cycle_step.keyword',
155
+ values: ['Step 1: Prepare', 'Step 2: Assess'],
156
+ type: 'all',
157
+ },
158
+ ],
159
+ },
160
+ );
161
+
162
+ expect(
163
+ container.querySelectorAll(
164
+ '.navigator-guide-preview-tags .navigator-tag',
165
+ ),
166
+ ).toHaveLength(1);
167
+ expect(
168
+ container.querySelector('.navigator-guide-preview-tags'),
169
+ ).toHaveTextContent('Step 1');
170
+ });
171
+ });
172
+
173
+ describe('NavigatorGuideContentView climate hazards', () => {
174
+ beforeEach(() => {
175
+ jest.clearAllMocks();
176
+ useAtom.mockReturnValue([1, jest.fn()]);
177
+ useSelector.mockReturnValue('en');
178
+ useHistory.mockReturnValue({ push: jest.fn() });
179
+ useGuideFacetOptions.mockReturnValue({});
180
+ });
181
+
182
+ it('uses the hazard tag style for a selected climate hazard', () => {
183
+ const { container } = renderGuide(
184
+ {
185
+ title: 'Guide tool',
186
+ href: '/tools/guide-tool',
187
+ cca_climate_impacts: { raw: ['Flooding'] },
188
+ },
189
+ {
190
+ filters: [
191
+ { field: 'cca_climate_impacts.keyword', values: ['Flooding'] },
192
+ ],
193
+ },
194
+ );
195
+
196
+ expect(
197
+ container.querySelector('.navigator-guide-preview-tags .hazard'),
198
+ ).toHaveTextContent('Flooding');
199
+ });
200
+ });
@@ -28,3 +28,18 @@ export const mergeGuideOptions = (
28
28
  !selectedValues.includes(option.value),
29
29
  }));
30
30
  };
31
+
32
+ export const sortAdaptationSteps = (options) =>
33
+ [...options].sort((a, b) => {
34
+ const aStep = /^Step\s+(\d+)\b/i.exec(a.value);
35
+ const bStep = /^Step\s+(\d+)\b/i.exec(b.value);
36
+
37
+ if (aStep && bStep) {
38
+ return (
39
+ Number(aStep[1]) - Number(bStep[1]) || a.value.localeCompare(b.value)
40
+ );
41
+ }
42
+ if (aStep) return -1;
43
+ if (bStep) return 1;
44
+ return a.value.localeCompare(b.value);
45
+ });
@@ -1,4 +1,4 @@
1
- import { mergeGuideOptions } from './utils';
1
+ import { mergeGuideOptions, sortAdaptationSteps } from './utils';
2
2
 
3
3
  describe('Navigator Guide utilities', () => {
4
4
  it('keeps unavailable values and marks them as disabled', () => {
@@ -37,4 +37,21 @@ describe('Navigator Guide utilities', () => {
37
37
  { value: 'Energy', count: 3, disabled: false },
38
38
  ]);
39
39
  });
40
+
41
+ it('keeps adaptation steps in numeric order regardless of match counts', () => {
42
+ const options = [
43
+ { value: 'Step 10: Review', count: 9 },
44
+ { value: 'Step 3: Plan', count: 20 },
45
+ { value: 'Step 1: Prepare', count: 1 },
46
+ { value: 'Step 2: Assess', count: 5 },
47
+ ];
48
+
49
+ expect(sortAdaptationSteps(options).map(({ value }) => value)).toEqual([
50
+ 'Step 1: Prepare',
51
+ 'Step 2: Assess',
52
+ 'Step 3: Plan',
53
+ 'Step 10: Review',
54
+ ]);
55
+ expect(options[0].value).toBe('Step 10: Review');
56
+ });
40
57
  });
@@ -1,5 +1,7 @@
1
1
  export { default as BannerTitle } from './theme/BannerTitle/BannerTitle';
2
2
  export { default as PortalMessage } from './theme/PortalMessage/PortalMessage';
3
+ export { default as TagOverflowPopup } from './theme/TagOverflowPopup';
4
+ export { default as ToolThumbnail } from './theme/ToolThumbnail/ToolThumbnail';
3
5
  export { default as TranslationDisclaimer } from './theme/TranslationDisclaimer/TranslationDisclaimer';
4
6
  export { default as ASTNavigation } from './theme/ASTNavigation/ASTNavigation';
5
7
  export { default as RedirectToLogin } from './theme/RedirectToLogin/RedirectToLogin';
@@ -136,12 +136,20 @@ export function parseMarkdownLines(content) {
136
136
  continue;
137
137
  }
138
138
 
139
- // List item: 1. Item or * Item or - Item
140
- const listMatch = trimmed.match(/^(\d+\.|[*+-])\s+(.*)$/);
139
+ // List item: 1. Item or * Item or - Item or [2] Item
140
+ const listMatch = trimmed.match(/^(\d+\.|[*+-]|\[\d+\])\s+(.*)$/);
141
141
  if (listMatch) {
142
142
  flushParagraph();
143
143
  const isOrdered = /^\d+\./.test(listMatch[1]);
144
- const itemText = listMatch[2].trim();
144
+ let itemText = listMatch[2].trim();
145
+
146
+ // Clean stray leading asterisks if odd number of asterisks
147
+ if (itemText.startsWith('**')) {
148
+ const count = (itemText.match(/\*\*/g) || []).length;
149
+ if (count % 2 === 1) {
150
+ itemText = itemText.replace(/^\*\*\s*/, '');
151
+ }
152
+ }
145
153
 
146
154
  const listItemNode = {
147
155
  type: 'listItem',
@@ -247,3 +255,121 @@ export function unwrapCardLists(children) {
247
255
  }
248
256
  }
249
257
  }
258
+
259
+ /**
260
+ * Recursively cleans stray unclosed leading asterisks (`**`) from paragraphs
261
+ * and list items. CommonMark leaves unmatched asterisks as literal text in
262
+ * the first text node when the closing asterisks cannot be paired.
263
+ */
264
+ export function cleanStrayLeadingAsterisks(node) {
265
+ if (!node) return;
266
+ if (node.type === 'paragraph' || node.type === 'listItem') {
267
+ if (Array.isArray(node.children)) {
268
+ const firstChild = node.children[0];
269
+ if (
270
+ firstChild?.type === 'text' &&
271
+ typeof firstChild.value === 'string' &&
272
+ /^\s*\*\*([^*]|$)/.test(firstChild.value)
273
+ ) {
274
+ firstChild.value = firstChild.value.replace(/^\s*\*\*\s*/, '');
275
+ }
276
+ }
277
+ }
278
+ if (Array.isArray(node.children)) {
279
+ node.children.forEach(cleanStrayLeadingAsterisks);
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Checks if a string starts with a citation reference marker like `[2] Title`
285
+ */
286
+ export function isCitationText(text) {
287
+ return typeof text === 'string' && /^\[\d+\]\s+/.test(text.trimStart());
288
+ }
289
+
290
+ /**
291
+ * Normalizes citation paragraphs following a list into list items so that
292
+ * reference lists like `[2] ... \n [3] ...` do not break out of an unordered
293
+ * or ordered list (such as under `### Limitations`). Also converts standalone
294
+ * citation paragraphs into lists.
295
+ */
296
+ export function normalizeCitationParagraphsToLists(children) {
297
+ if (!Array.isArray(children)) return;
298
+
299
+ for (let i = 0; i < children.length; i += 1) {
300
+ const curr = children[i];
301
+
302
+ // Case 1: Existing list followed by citation paragraph(s)
303
+ if (curr.type === 'list' && Array.isArray(curr.children)) {
304
+ while (i + 1 < children.length) {
305
+ const next = children[i + 1];
306
+ if (!next || next.type !== 'paragraph') break;
307
+ const fullText = getNodeText(next).trim();
308
+ if (!isCitationText(fullText)) break;
309
+
310
+ // If single text node with multiple newline-separated lines
311
+ if (
312
+ Array.isArray(next.children) &&
313
+ next.children.length === 1 &&
314
+ next.children[0].type === 'text'
315
+ ) {
316
+ const lines = next.children[0].value
317
+ .split(/\r?\n/)
318
+ .filter((l) => l.trim());
319
+ if (lines.every(isCitationText)) {
320
+ lines.forEach((line) => {
321
+ curr.children.push({
322
+ type: 'listItem',
323
+ children: [
324
+ {
325
+ type: 'paragraph',
326
+ children: [{ type: 'text', value: line.trim() }],
327
+ },
328
+ ],
329
+ });
330
+ });
331
+ children.splice(i + 1, 1);
332
+ continue;
333
+ }
334
+ }
335
+
336
+ curr.children.push({
337
+ type: 'listItem',
338
+ children: [next],
339
+ });
340
+ children.splice(i + 1, 1);
341
+ }
342
+ } else if (curr.type === 'paragraph') {
343
+ // Case 2: Standalone citation paragraph not preceded by a list
344
+ const fullText = getNodeText(curr).trim();
345
+ if (isCitationText(fullText)) {
346
+ if (
347
+ Array.isArray(curr.children) &&
348
+ curr.children.length === 1 &&
349
+ curr.children[0].type === 'text'
350
+ ) {
351
+ const lines = curr.children[0].value
352
+ .split(/\r?\n/)
353
+ .filter((l) => l.trim());
354
+ if (lines.every(isCitationText)) {
355
+ const listItems = lines.map((line) => ({
356
+ type: 'listItem',
357
+ children: [
358
+ {
359
+ type: 'paragraph',
360
+ children: [{ type: 'text', value: line.trim() }],
361
+ },
362
+ ],
363
+ }));
364
+ children.splice(i, 1, {
365
+ type: 'list',
366
+ ordered: false,
367
+ children: listItems,
368
+ });
369
+ i += listItems.length - 1;
370
+ }
371
+ }
372
+ }
373
+ }
374
+ }
375
+ }
@@ -1,7 +1,10 @@
1
1
  import {
2
2
  DOC_MARKER_RE,
3
+ cleanStrayLeadingAsterisks,
3
4
  deduplicateAdjacentHeadings,
5
+ isCitationText,
4
6
  isNextStepsHeading,
7
+ normalizeCitationParagraphsToLists,
5
8
  parseMarkdownLines,
6
9
  shouldUnwrapCodeNode,
7
10
  unwrapCardLists,
@@ -205,3 +208,207 @@ describe('unwrapCardLists', () => {
205
208
  expect(children[1].value).toBe('Tool 2');
206
209
  });
207
210
  });
211
+
212
+ describe('cleanStrayLeadingAsterisks', () => {
213
+ it('strips unmatched leading ** from paragraphs and list items preceding strong elements', () => {
214
+ const node = {
215
+ type: 'listItem',
216
+ children: [
217
+ {
218
+ type: 'paragraph',
219
+ children: [
220
+ {
221
+ type: 'text',
222
+ value: '**Run a rapid, multi-hazard screening with the ',
223
+ },
224
+ {
225
+ type: 'strong',
226
+ children: [
227
+ {
228
+ type: 'text',
229
+ value: 'Pathways2Resilience Climate Toolbox',
230
+ },
231
+ ],
232
+ },
233
+ {
234
+ type: 'text',
235
+ value: ' to identify climate threats.',
236
+ },
237
+ ],
238
+ },
239
+ ],
240
+ };
241
+
242
+ cleanStrayLeadingAsterisks(node);
243
+ const textNode = node.children[0].children[0];
244
+ expect(textNode.value).toBe(
245
+ 'Run a rapid, multi-hazard screening with the ',
246
+ );
247
+ });
248
+
249
+ it('leaves paragraphs without leading ** untouched', () => {
250
+ const node = {
251
+ type: 'paragraph',
252
+ children: [
253
+ {
254
+ type: 'text',
255
+ value: 'Normal text without asterisks',
256
+ },
257
+ ],
258
+ };
259
+
260
+ cleanStrayLeadingAsterisks(node);
261
+ expect(node.children[0].value).toBe('Normal text without asterisks');
262
+ });
263
+ });
264
+
265
+ describe('isCitationText', () => {
266
+ it('identifies citation markers', () => {
267
+ expect(isCitationText('[2] Pathways2Resilience Climate Toolbox')).toBe(
268
+ true,
269
+ );
270
+ expect(isCitationText(' [15] Multi-digit citation')).toBe(true);
271
+ expect(isCitationText('Normal paragraph text')).toBe(false);
272
+ expect(isCitationText('1. Numbered list item')).toBe(false);
273
+ expect(isCitationText(null)).toBe(false);
274
+ });
275
+ });
276
+
277
+ describe('normalizeCitationParagraphsToLists', () => {
278
+ it('appends newline-separated citation lines following a list as list items', () => {
279
+ const children = [
280
+ {
281
+ type: 'list',
282
+ ordered: false,
283
+ children: [
284
+ {
285
+ type: 'listItem',
286
+ children: [
287
+ {
288
+ type: 'paragraph',
289
+ children: [
290
+ {
291
+ type: 'text',
292
+ value: 'The Greek Climate Change Adaptation Hub limitation',
293
+ },
294
+ ],
295
+ },
296
+ ],
297
+ },
298
+ ],
299
+ },
300
+ {
301
+ type: 'paragraph',
302
+ children: [
303
+ {
304
+ type: 'text',
305
+ value:
306
+ '[2] Pathways2Resilience meta-tool\n[3] NATURE DEMO DST analysis\n[4] Greek Hub resilience audit',
307
+ },
308
+ ],
309
+ },
310
+ ];
311
+
312
+ normalizeCitationParagraphsToLists(children);
313
+ // Paragraph should be merged into list
314
+ expect(children).toHaveLength(1);
315
+ const list = children[0];
316
+ expect(list.type).toBe('list');
317
+ expect(list.children).toHaveLength(4);
318
+ expect(list.children[0].children[0].children[0].value).toBe(
319
+ 'The Greek Climate Change Adaptation Hub limitation',
320
+ );
321
+ expect(list.children[1].children[0].children[0].value).toBe(
322
+ '[2] Pathways2Resilience meta-tool',
323
+ );
324
+ expect(list.children[2].children[0].children[0].value).toBe(
325
+ '[3] NATURE DEMO DST analysis',
326
+ );
327
+ expect(list.children[3].children[0].children[0].value).toBe(
328
+ '[4] Greek Hub resilience audit',
329
+ );
330
+ });
331
+
332
+ it('appends consecutive citation paragraphs to a preceding list', () => {
333
+ const children = [
334
+ {
335
+ type: 'list',
336
+ ordered: false,
337
+ children: [
338
+ {
339
+ type: 'listItem',
340
+ children: [
341
+ {
342
+ type: 'paragraph',
343
+ children: [{ type: 'text', value: 'Initial bullet' }],
344
+ },
345
+ ],
346
+ },
347
+ ],
348
+ },
349
+ {
350
+ type: 'paragraph',
351
+ children: [{ type: 'text', value: '[2] Second item' }],
352
+ },
353
+ {
354
+ type: 'paragraph',
355
+ children: [{ type: 'text', value: '[3] Third item' }],
356
+ },
357
+ ];
358
+
359
+ normalizeCitationParagraphsToLists(children);
360
+ expect(children).toHaveLength(1);
361
+ expect(children[0].children).toHaveLength(3);
362
+ });
363
+
364
+ it('converts standalone citation paragraphs into a list', () => {
365
+ const children = [
366
+ {
367
+ type: 'paragraph',
368
+ children: [
369
+ {
370
+ type: 'text',
371
+ value: '[2] Item A\n[3] Item B',
372
+ },
373
+ ],
374
+ },
375
+ ];
376
+
377
+ normalizeCitationParagraphsToLists(children);
378
+ expect(children).toHaveLength(1);
379
+ expect(children[0].type).toBe('list');
380
+ expect(children[0].children).toHaveLength(2);
381
+ });
382
+
383
+ it('leaves non-citation paragraphs untouched', () => {
384
+ const children = [
385
+ {
386
+ type: 'paragraph',
387
+ children: [{ type: 'text', value: 'Regular paragraph' }],
388
+ },
389
+ ];
390
+
391
+ normalizeCitationParagraphsToLists(children);
392
+ expect(children).toHaveLength(1);
393
+ expect(children[0].type).toBe('paragraph');
394
+ });
395
+ });
396
+
397
+ describe('parseMarkdownLines enhancements', () => {
398
+ it('parses [2] marker lines as list items and cleans stray leading asterisks', () => {
399
+ const markdown =
400
+ '1. **Run a screening with the **Pathways2Resilience Toolbox** for risks\n[2] Citation line description';
401
+ const nodes = parseMarkdownLines(markdown);
402
+ expect(nodes).toHaveLength(2);
403
+ expect(nodes[0].type).toBe('list');
404
+ expect(nodes[0].ordered).toBe(true);
405
+ // Unmatched leading ** was cleaned, leaving the properly matched **Toolbox**
406
+ expect(nodes[0].children[0].children[0].children[0].value).toBe(
407
+ 'Run a screening with the **Pathways2Resilience Toolbox** for risks',
408
+ );
409
+ expect(nodes[1].type).toBe('list');
410
+ expect(nodes[1].children[0].children[0].children[0].value).toBe(
411
+ 'Citation line description',
412
+ );
413
+ });
414
+ });
@@ -19,7 +19,9 @@
19
19
 
20
20
  import {
21
21
  DOC_MARKER_RE,
22
+ cleanStrayLeadingAsterisks,
22
23
  deduplicateAdjacentHeadings,
24
+ normalizeCitationParagraphsToLists,
23
25
  parseMarkdownLines,
24
26
  shouldUnwrapCodeNode,
25
27
  unwrapCardLists,
@@ -132,6 +134,12 @@ export const remarkCcaDocCards = function () {
132
134
  // 5. Unwrap paragraphs that only contain ccaDocCard nodes
133
135
  unwrapCardParagraphs(tree.children);
134
136
 
137
+ // 6. Normalize citation paragraphs following lists into list items
138
+ normalizeCitationParagraphsToLists(tree.children);
139
+
140
+ // 7. Clean stray unclosed leading asterisks in list items and paragraphs
141
+ cleanStrayLeadingAsterisks(tree);
142
+
135
143
  return tree;
136
144
  };
137
145
  };
@@ -249,4 +249,76 @@ describe('remarkCcaDocCards', () => {
249
249
  expect(result.children[1].type).toBe('ccaDocCard');
250
250
  expect(result.children[1].value).toBe('Tool B');
251
251
  });
252
+
253
+ it('normalizes citation paragraphs following a list so they become list items', () => {
254
+ const tree = {
255
+ type: 'root',
256
+ children: [
257
+ {
258
+ type: 'list',
259
+ ordered: false,
260
+ children: [
261
+ {
262
+ type: 'listItem',
263
+ children: [
264
+ {
265
+ type: 'paragraph',
266
+ children: [
267
+ { type: 'text', value: 'National context limitation' },
268
+ ],
269
+ },
270
+ ],
271
+ },
272
+ ],
273
+ },
274
+ {
275
+ type: 'paragraph',
276
+ children: [
277
+ {
278
+ type: 'text',
279
+ value:
280
+ '[2] Pathways2Resilience Toolbox\n[3] NATURE DEMO DST analysis',
281
+ },
282
+ ],
283
+ },
284
+ ],
285
+ };
286
+
287
+ const result = applyPlugin(tree);
288
+ expect(result.children).toHaveLength(1);
289
+ expect(result.children[0].type).toBe('list');
290
+ expect(result.children[0].children).toHaveLength(3);
291
+ expect(result.children[0].children[1].children[0].children[0].value).toBe(
292
+ '[2] Pathways2Resilience Toolbox',
293
+ );
294
+ expect(result.children[0].children[2].children[0].children[0].value).toBe(
295
+ '[3] NATURE DEMO DST analysis',
296
+ );
297
+ });
298
+
299
+ it('cleans stray leading asterisks in list items and paragraphs', () => {
300
+ const tree = {
301
+ type: 'root',
302
+ children: [
303
+ {
304
+ type: 'paragraph',
305
+ children: [
306
+ {
307
+ type: 'text',
308
+ value: '**Run a rapid screening with the ',
309
+ },
310
+ {
311
+ type: 'strong',
312
+ children: [{ type: 'text', value: 'Tool A' }],
313
+ },
314
+ ],
315
+ },
316
+ ],
317
+ };
318
+
319
+ const result = applyPlugin(tree);
320
+ expect(result.children[0].children[0].value).toBe(
321
+ 'Run a rapid screening with the ',
322
+ );
323
+ });
252
324
  });
@@ -10,6 +10,7 @@
10
10
 
11
11
  import {
12
12
  NEXT_STEPS_HEADING_RE,
13
+ cleanStrayLeadingAsterisks,
13
14
  getNodeText,
14
15
  isNextStepsHeading,
15
16
  parseMarkdownLines,
@@ -61,6 +62,9 @@ function transformContainer(children) {
61
62
  children: collected,
62
63
  };
63
64
 
65
+ // Clean unclosed leading asterisks on list items inside container
66
+ cleanStrayLeadingAsterisks(containerNode);
67
+
64
68
  // Replace heading and all collected siblings with the container node
65
69
  children.splice(i, 1 + (j - (i + 1)), containerNode);
66
70
  } else if (