@eeacms/volto-cca-policy 1.0.13 → 1.0.15
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.
- package/.eslintrc.js +1 -1
- package/CHANGELOG.md +39 -1
- package/package.json +3 -1
- package/src/components/manage/Blocks/ChatbotCatalogue/CatalogueChatView.jsx +48 -0
- package/src/components/manage/Blocks/ChatbotCatalogue/CatalogueChatView.test.jsx +73 -0
- package/src/components/manage/Blocks/ChatbotCatalogue/DocumentCard.jsx +226 -0
- package/src/components/manage/Blocks/ChatbotCatalogue/DocumentCard.test.jsx +153 -0
- package/src/components/manage/Blocks/ChatbotCatalogue/SuggestedNextSteps.jsx +20 -0
- package/src/components/manage/Blocks/ChatbotCatalogue/SuggestedNextSteps.test.jsx +34 -0
- package/src/components/manage/Blocks/ChatbotCatalogue/catalogue-chat.less +136 -0
- package/src/components/manage/Blocks/ChatbotCatalogue/docCards.js +74 -0
- package/src/components/manage/Blocks/ChatbotCatalogue/docCards.test.js +145 -0
- package/src/components/manage/Blocks/ChatbotCatalogue/index.js +24 -0
- package/src/components/manage/Blocks/ChatbotCatalogue/index.test.jsx +44 -0
- package/src/components/manage/Blocks/ChatbotCatalogue/nextSteps.js +79 -0
- package/src/components/manage/Blocks/ChatbotCatalogue/nextSteps.test.js +168 -0
- package/src/components/manage/Blocks/ChatbotCatalogue/useCatalogueDoc.js +116 -0
- package/src/components/manage/Blocks/ChatbotCatalogue/useCatalogueDoc.test.js +149 -0
- package/src/components/manage/Blocks/index.js +2 -0
- package/theme/globals/navigator.less +40 -23
- package/src/customizations/@eeacms/volto-banner/StagingBanner.jsx +0 -5
- package/src/customizations/@eeacms/volto-banner/index.js +0 -3
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Styles for the inline document cards in the CCA "catalogue" chatbot
|
|
2
|
+
// presentation. The rest of the chat layout (conversation, rows, user
|
|
3
|
+
// bubbles, "New chat" button, streaming placeholder, ...) is provided by the
|
|
4
|
+
// classic chatbot `style.less` because this variation reuses the classic
|
|
5
|
+
// `ChatWindow`.
|
|
6
|
+
|
|
7
|
+
.catalogue-chat-navigator-card {
|
|
8
|
+
width: 100%;
|
|
9
|
+
|
|
10
|
+
.navigator-catalogue-item {
|
|
11
|
+
margin-bottom: 0.5rem;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.catalogue-chat-card-inline {
|
|
16
|
+
margin: 8px 0;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Basic fallback card, shown until the globalsearch `_doc` lookup (by URL)
|
|
20
|
+
// resolves and upgrades it to the full Navigator card.
|
|
21
|
+
.catalogue-chat-card {
|
|
22
|
+
display: flex;
|
|
23
|
+
flex-direction: column;
|
|
24
|
+
padding: 12px;
|
|
25
|
+
border: 1px solid #d9d9d9;
|
|
26
|
+
border-radius: 6px;
|
|
27
|
+
background: white;
|
|
28
|
+
gap: 6px;
|
|
29
|
+
|
|
30
|
+
&:hover {
|
|
31
|
+
border-color: #0067b1;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.catalogue-chat-card-header {
|
|
35
|
+
display: flex;
|
|
36
|
+
align-items: baseline;
|
|
37
|
+
gap: 8px;
|
|
38
|
+
|
|
39
|
+
.chat-citation {
|
|
40
|
+
cursor: default;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.catalogue-chat-card-title {
|
|
45
|
+
color: #0067b1;
|
|
46
|
+
font-weight: 600;
|
|
47
|
+
line-height: 1.3;
|
|
48
|
+
text-decoration: none;
|
|
49
|
+
|
|
50
|
+
&:hover {
|
|
51
|
+
text-decoration: underline;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.catalogue-chat-card-blurb {
|
|
56
|
+
display: -webkit-box;
|
|
57
|
+
overflow: hidden;
|
|
58
|
+
-webkit-box-orient: vertical;
|
|
59
|
+
color: #555;
|
|
60
|
+
font-size: 0.85em;
|
|
61
|
+
-webkit-line-clamp: 3;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.catalogue-chat-card-footer {
|
|
65
|
+
display: flex;
|
|
66
|
+
justify-content: space-between;
|
|
67
|
+
padding-top: 6px;
|
|
68
|
+
border-top: 1px solid #eee;
|
|
69
|
+
margin-top: auto;
|
|
70
|
+
color: #888;
|
|
71
|
+
font-size: 0.75em;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// "Suggested next steps" callout box
|
|
76
|
+
.cca-suggested-next-steps {
|
|
77
|
+
padding: 1rem 1.25rem;
|
|
78
|
+
border: 1px solid #dbe7e1;
|
|
79
|
+
border-radius: 8px;
|
|
80
|
+
margin: 1.25rem 0;
|
|
81
|
+
background-color: #f4f9f7;
|
|
82
|
+
|
|
83
|
+
.cca-suggested-next-steps-header {
|
|
84
|
+
display: flex;
|
|
85
|
+
align-items: center;
|
|
86
|
+
margin-bottom: 0.75rem;
|
|
87
|
+
gap: 0.5rem;
|
|
88
|
+
|
|
89
|
+
.icon {
|
|
90
|
+
margin: 0;
|
|
91
|
+
color: #008369;
|
|
92
|
+
font-size: 1.25rem;
|
|
93
|
+
line-height: 1;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.cca-suggested-next-steps-title {
|
|
97
|
+
color: #004b3b;
|
|
98
|
+
font-size: 1rem;
|
|
99
|
+
font-weight: 600;
|
|
100
|
+
line-height: 1.3;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.cca-suggested-next-steps-content {
|
|
105
|
+
ol,
|
|
106
|
+
ul {
|
|
107
|
+
padding-left: 1.25rem;
|
|
108
|
+
margin: 0;
|
|
109
|
+
|
|
110
|
+
li {
|
|
111
|
+
margin-bottom: 0.5rem;
|
|
112
|
+
color: #333;
|
|
113
|
+
line-height: 1.5;
|
|
114
|
+
|
|
115
|
+
&:last-child {
|
|
116
|
+
margin-bottom: 0;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
strong {
|
|
120
|
+
color: #111;
|
|
121
|
+
font-weight: 600;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
p {
|
|
127
|
+
margin-bottom: 0.5rem;
|
|
128
|
+
color: #333;
|
|
129
|
+
line-height: 1.5;
|
|
130
|
+
|
|
131
|
+
&:last-child {
|
|
132
|
+
margin-bottom: 0;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remark plugin: converts `![[doc: Document Title]]` markers in the answer
|
|
3
|
+
* text into custom `cca-doc-card` elements.
|
|
4
|
+
*
|
|
5
|
+
* react-markdown maps the custom element (via `data.hName`) to a component
|
|
6
|
+
* supplied by the caller through `extraMarkdownComponents`
|
|
7
|
+
* (see MessageTextRenderer pass-through in @eeacms/volto-eea-chatbot).
|
|
8
|
+
*
|
|
9
|
+
* Markers are matched in ANY text node (own line, inside list items,
|
|
10
|
+
* mid-sentence), not only whole paragraphs — the LLM does not always put
|
|
11
|
+
* them on a line of their own.
|
|
12
|
+
*
|
|
13
|
+
* The assistant is prompted (Onyx side) to list relevant documents with
|
|
14
|
+
* markers, e.g.:
|
|
15
|
+
*
|
|
16
|
+
* ![[doc: Europe 2020: The EU's platform for climate action]]
|
|
17
|
+
* ![[doc: France: National Adaptation Strategy]]
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const DOC_MARKER_RE = /!\[\[\s*doc:\s*([^\]\r\n]+)\]\]/g;
|
|
21
|
+
|
|
22
|
+
function makeCardNode(title) {
|
|
23
|
+
return {
|
|
24
|
+
type: 'ccaDocCard',
|
|
25
|
+
value: title,
|
|
26
|
+
data: { hName: 'cca-doc-card', hProperties: { title } },
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function walkNodes(children, visit) {
|
|
31
|
+
for (let i = 0; i < children.length; i += 1) {
|
|
32
|
+
const replaced = visit(children[i], i, children);
|
|
33
|
+
if (Array.isArray(replaced)) {
|
|
34
|
+
// Splice the replacement nodes in place of the visited one. The
|
|
35
|
+
// replacement nodes (text / ccaDocCard) have no children to walk.
|
|
36
|
+
children.splice(i, 1, ...replaced);
|
|
37
|
+
i += replaced.length - 1;
|
|
38
|
+
} else if (children[i].children) {
|
|
39
|
+
walkNodes(children[i].children, visit);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const remarkCcaDocCards = () => (tree) => {
|
|
45
|
+
walkNodes(tree.children || [], (node) => {
|
|
46
|
+
if (node.type !== 'text' || typeof node.value !== 'string') {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
DOC_MARKER_RE.lastIndex = 0;
|
|
50
|
+
if (!DOC_MARKER_RE.test(node.value)) {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const parts = [];
|
|
55
|
+
let last = 0;
|
|
56
|
+
let match;
|
|
57
|
+
DOC_MARKER_RE.lastIndex = 0;
|
|
58
|
+
while ((match = DOC_MARKER_RE.exec(node.value)) !== null) {
|
|
59
|
+
if (match.index > last) {
|
|
60
|
+
parts.push({
|
|
61
|
+
type: 'text',
|
|
62
|
+
value: node.value.slice(last, match.index),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
parts.push(makeCardNode(match[1].trim()));
|
|
66
|
+
last = match.index + match[0].length;
|
|
67
|
+
}
|
|
68
|
+
if (last < node.value.length) {
|
|
69
|
+
parts.push({ type: 'text', value: node.value.slice(last) });
|
|
70
|
+
}
|
|
71
|
+
return parts;
|
|
72
|
+
});
|
|
73
|
+
return tree;
|
|
74
|
+
};
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { remarkCcaDocCards } from './docCards';
|
|
2
|
+
|
|
3
|
+
const applyPlugin = (tree) => remarkCcaDocCards()(tree);
|
|
4
|
+
|
|
5
|
+
const cards = (tree) =>
|
|
6
|
+
tree.children.filter((n) => n.type === 'ccaDocCard').map((n) => n.value);
|
|
7
|
+
|
|
8
|
+
const cardHNames = (tree) =>
|
|
9
|
+
tree.children.filter((n) => n.type === 'ccaDocCard').map((n) => n.data.hName);
|
|
10
|
+
|
|
11
|
+
describe('remarkCcaDocCards', () => {
|
|
12
|
+
it('leaves text without markers untouched', () => {
|
|
13
|
+
const tree = {
|
|
14
|
+
type: 'root',
|
|
15
|
+
children: [{ type: 'text', value: 'No documents here.' }],
|
|
16
|
+
};
|
|
17
|
+
const result = applyPlugin(tree);
|
|
18
|
+
expect(result).toBe(tree);
|
|
19
|
+
expect(cards(result)).toHaveLength(0);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('converts a marker on its own line into a cca-doc-card element', () => {
|
|
23
|
+
const tree = {
|
|
24
|
+
type: 'root',
|
|
25
|
+
children: [{ type: 'text', value: '![[doc: France NAS]]' }],
|
|
26
|
+
};
|
|
27
|
+
const result = applyPlugin(tree);
|
|
28
|
+
expect(result.children).toHaveLength(1);
|
|
29
|
+
expect(result.children[0]).toEqual({
|
|
30
|
+
type: 'ccaDocCard',
|
|
31
|
+
value: 'France NAS',
|
|
32
|
+
data: { hName: 'cca-doc-card', hProperties: { title: 'France NAS' } },
|
|
33
|
+
});
|
|
34
|
+
expect(cardHNames(result)).toEqual(['cca-doc-card']);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('trims whitespace around the title', () => {
|
|
38
|
+
const tree = {
|
|
39
|
+
type: 'root',
|
|
40
|
+
children: [
|
|
41
|
+
{ type: 'text', value: '![[ doc: Climate Action Plan ]]' },
|
|
42
|
+
],
|
|
43
|
+
};
|
|
44
|
+
const result = applyPlugin(tree);
|
|
45
|
+
expect(cards(result)).toEqual(['Climate Action Plan']);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('keeps text before and after a mid-sentence marker', () => {
|
|
49
|
+
const tree = {
|
|
50
|
+
type: 'root',
|
|
51
|
+
children: [
|
|
52
|
+
{
|
|
53
|
+
type: 'text',
|
|
54
|
+
value: 'See ![[doc: EU Adaptation Strategy]] for details, and more.',
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
};
|
|
58
|
+
const result = applyPlugin(tree);
|
|
59
|
+
expect(result.children).toHaveLength(3);
|
|
60
|
+
expect(result.children[0]).toEqual({
|
|
61
|
+
type: 'text',
|
|
62
|
+
value: 'See ',
|
|
63
|
+
});
|
|
64
|
+
expect(result.children[1]).toEqual({
|
|
65
|
+
type: 'ccaDocCard',
|
|
66
|
+
value: 'EU Adaptation Strategy',
|
|
67
|
+
data: {
|
|
68
|
+
hName: 'cca-doc-card',
|
|
69
|
+
hProperties: { title: 'EU Adaptation Strategy' },
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
expect(result.children[2]).toEqual({
|
|
73
|
+
type: 'text',
|
|
74
|
+
value: ' for details, and more.',
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('converts several markers in the same text node', () => {
|
|
79
|
+
const tree = {
|
|
80
|
+
type: 'root',
|
|
81
|
+
children: [
|
|
82
|
+
{
|
|
83
|
+
type: 'text',
|
|
84
|
+
value: '![[doc: One]]\n![[doc: Two]]',
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
};
|
|
88
|
+
const result = applyPlugin(tree);
|
|
89
|
+
expect(cards(result)).toEqual(['One', 'Two']);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('matches markers with a colon in the title', () => {
|
|
93
|
+
const tree = {
|
|
94
|
+
type: 'root',
|
|
95
|
+
children: [
|
|
96
|
+
{ type: 'text', value: '![[doc: Europe 2020: The EU platform]]' },
|
|
97
|
+
],
|
|
98
|
+
};
|
|
99
|
+
const result = applyPlugin(tree);
|
|
100
|
+
expect(cards(result)).toEqual(['Europe 2020: The EU platform']);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('converts markers nested inside list items', () => {
|
|
104
|
+
const tree = {
|
|
105
|
+
type: 'root',
|
|
106
|
+
children: [
|
|
107
|
+
{
|
|
108
|
+
type: 'list',
|
|
109
|
+
children: [
|
|
110
|
+
{
|
|
111
|
+
type: 'listItem',
|
|
112
|
+
children: [
|
|
113
|
+
{
|
|
114
|
+
type: 'paragraph',
|
|
115
|
+
children: [
|
|
116
|
+
{ type: 'text', value: 'See ![[doc: France NAS]] too' },
|
|
117
|
+
],
|
|
118
|
+
},
|
|
119
|
+
],
|
|
120
|
+
},
|
|
121
|
+
],
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
};
|
|
125
|
+
const result = applyPlugin(tree);
|
|
126
|
+
const paragraph = result.children[0].children[0].children[0];
|
|
127
|
+
expect(paragraph.children).toHaveLength(3);
|
|
128
|
+
expect(paragraph.children[1].type).toBe('ccaDocCard');
|
|
129
|
+
expect(paragraph.children[1].value).toBe('France NAS');
|
|
130
|
+
expect(paragraph.children[2]).toEqual({ type: 'text', value: ' too' });
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('ignores non-text nodes', () => {
|
|
134
|
+
const tree = {
|
|
135
|
+
type: 'root',
|
|
136
|
+
children: [
|
|
137
|
+
{ type: 'html', value: '![[doc: not a text node]]' },
|
|
138
|
+
{ type: 'text', value: '![[doc: real]]' },
|
|
139
|
+
],
|
|
140
|
+
};
|
|
141
|
+
const result = applyPlugin(tree);
|
|
142
|
+
expect(cards(result)).toEqual(['real']);
|
|
143
|
+
expect(result.children[0].value).toBe('![[doc: not a text node]]');
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import CatalogueChatView from './CatalogueChatView';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Registers the CCA "catalogue" presentation variation on the AI Chatbot
|
|
5
|
+
* block (from @eeacms/volto-eea-chatbot).
|
|
6
|
+
*
|
|
7
|
+
* Pattern: cross-addon variation push, same as ./TabsBlock pushing the
|
|
8
|
+
* "spotlight" variation onto the tabs block.
|
|
9
|
+
*/
|
|
10
|
+
export default function applyConfig(config) {
|
|
11
|
+
const block = config.blocks.blocksConfig.eeaChatbot;
|
|
12
|
+
if (block) {
|
|
13
|
+
block.variations = block.variations || [];
|
|
14
|
+
if (!block.variations.find((v) => v.id === 'catalogue')) {
|
|
15
|
+
block.variations.push({
|
|
16
|
+
id: 'catalogue',
|
|
17
|
+
title: 'Catalogue (CCA)',
|
|
18
|
+
isDefault: false,
|
|
19
|
+
view: CatalogueChatView,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return config;
|
|
24
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import applyConfig from './index';
|
|
2
|
+
|
|
3
|
+
jest.mock('./CatalogueChatView', () => () => <div />);
|
|
4
|
+
|
|
5
|
+
const makeConfig = (variations) => ({
|
|
6
|
+
blocks: {
|
|
7
|
+
blocksConfig: {
|
|
8
|
+
eeaChatbot: variations ? { variations } : {},
|
|
9
|
+
},
|
|
10
|
+
},
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe('ChatbotCatalogue applyConfig', () => {
|
|
14
|
+
it('registers the catalogue variation on the eeaChatbot block', () => {
|
|
15
|
+
const config = applyConfig(makeConfig());
|
|
16
|
+
const variations = config.blocks.blocksConfig.eeaChatbot.variations;
|
|
17
|
+
expect(variations).toHaveLength(1);
|
|
18
|
+
expect(variations[0]).toMatchObject({
|
|
19
|
+
id: 'catalogue',
|
|
20
|
+
title: 'Catalogue (CCA)',
|
|
21
|
+
isDefault: false,
|
|
22
|
+
});
|
|
23
|
+
expect(typeof variations[0].view).toBe('function');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('does not register a duplicate when called twice', () => {
|
|
27
|
+
let config = applyConfig(makeConfig());
|
|
28
|
+
config = applyConfig(config);
|
|
29
|
+
const variations = config.blocks.blocksConfig.eeaChatbot.variations;
|
|
30
|
+
expect(variations.filter((v) => v.id === 'catalogue')).toHaveLength(1);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('keeps existing variations and their order', () => {
|
|
34
|
+
const classic = { id: 'classic', isDefault: true, view: () => <div /> };
|
|
35
|
+
const config = applyConfig(makeConfig([classic]));
|
|
36
|
+
const variations = config.blocks.blocksConfig.eeaChatbot.variations;
|
|
37
|
+
expect(variations.map((v) => v.id)).toEqual(['classic', 'catalogue']);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('is a no-op when the eeaChatbot block is not registered', () => {
|
|
41
|
+
const config = { blocks: { blocksConfig: {} } };
|
|
42
|
+
expect(applyConfig(config)).toEqual(config);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remark plugin: transforms "Suggested next steps" (or "How to use them")
|
|
3
|
+
* headings and their following lists/content into a custom
|
|
4
|
+
* `cca-suggested-next-steps` container element.
|
|
5
|
+
*
|
|
6
|
+
* react-markdown maps the custom element (via `data.hName`) to a component
|
|
7
|
+
* supplied by the caller through `extraMarkdownComponents`
|
|
8
|
+
* (see CatalogueChatView and MessageTextRenderer).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const NEXT_STEPS_HEADING_RE =
|
|
12
|
+
/^(suggested\s+next\s+steps|next\s+steps|how\s+to\s+use(\s+(them|these|this|the\s+tools))?)\b/i;
|
|
13
|
+
|
|
14
|
+
export function getNodeText(node) {
|
|
15
|
+
if (!node) return '';
|
|
16
|
+
if (typeof node.value === 'string') return node.value;
|
|
17
|
+
if (Array.isArray(node.children)) {
|
|
18
|
+
return node.children.map(getNodeText).join('');
|
|
19
|
+
}
|
|
20
|
+
return '';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isNextStepsHeading(node) {
|
|
24
|
+
if (!node || node.type !== 'heading') return false;
|
|
25
|
+
const text = getNodeText(node).trim();
|
|
26
|
+
return NEXT_STEPS_HEADING_RE.test(text);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function transformContainer(children) {
|
|
30
|
+
for (let i = 0; i < children.length; i += 1) {
|
|
31
|
+
const node = children[i];
|
|
32
|
+
if (isNextStepsHeading(node)) {
|
|
33
|
+
const rawTitle = getNodeText(node).trim().replace(/:$/, '').trim();
|
|
34
|
+
const title = rawTitle || 'Suggested next steps';
|
|
35
|
+
|
|
36
|
+
const collected = [];
|
|
37
|
+
let j = i + 1;
|
|
38
|
+
while (j < children.length) {
|
|
39
|
+
const sibling = children[j];
|
|
40
|
+
if (
|
|
41
|
+
sibling.type === 'heading' ||
|
|
42
|
+
sibling.type === 'thematicBreak' ||
|
|
43
|
+
sibling.type === 'ccaDocCard'
|
|
44
|
+
) {
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
collected.push(sibling);
|
|
48
|
+
j += 1;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const containerNode = {
|
|
52
|
+
type: 'ccaNextSteps',
|
|
53
|
+
data: {
|
|
54
|
+
hName: 'cca-suggested-next-steps',
|
|
55
|
+
hProperties: {
|
|
56
|
+
title,
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
children: collected,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// Replace heading and all collected siblings with the container node
|
|
63
|
+
children.splice(i, 1 + collected.length, containerNode);
|
|
64
|
+
} else if (
|
|
65
|
+
node.children &&
|
|
66
|
+
Array.isArray(node.children) &&
|
|
67
|
+
node.type !== 'ccaNextSteps'
|
|
68
|
+
) {
|
|
69
|
+
transformContainer(node.children);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const remarkCcaNextSteps = () => (tree) => {
|
|
75
|
+
if (tree && Array.isArray(tree.children)) {
|
|
76
|
+
transformContainer(tree.children);
|
|
77
|
+
}
|
|
78
|
+
return tree;
|
|
79
|
+
};
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import {
|
|
2
|
+
remarkCcaNextSteps,
|
|
3
|
+
isNextStepsHeading,
|
|
4
|
+
getNodeText,
|
|
5
|
+
} from './nextSteps';
|
|
6
|
+
|
|
7
|
+
const applyPlugin = (tree) => remarkCcaNextSteps()(tree);
|
|
8
|
+
|
|
9
|
+
describe('nextSteps utilities', () => {
|
|
10
|
+
it('extracts nested text recursively', () => {
|
|
11
|
+
const node = {
|
|
12
|
+
type: 'heading',
|
|
13
|
+
children: [
|
|
14
|
+
{ type: 'text', value: 'Suggested ' },
|
|
15
|
+
{
|
|
16
|
+
type: 'strong',
|
|
17
|
+
children: [{ type: 'text', value: 'next steps' }],
|
|
18
|
+
},
|
|
19
|
+
],
|
|
20
|
+
};
|
|
21
|
+
expect(getNodeText(node)).toBe('Suggested next steps');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('recognises next steps heading patterns', () => {
|
|
25
|
+
const makeHeading = (text) => ({
|
|
26
|
+
type: 'heading',
|
|
27
|
+
depth: 3,
|
|
28
|
+
children: [{ type: 'text', value: text }],
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
expect(isNextStepsHeading(makeHeading('Suggested next steps'))).toBe(true);
|
|
32
|
+
expect(isNextStepsHeading(makeHeading('Suggested next steps:'))).toBe(true);
|
|
33
|
+
expect(isNextStepsHeading(makeHeading('### Suggested Next Steps'))).toBe(
|
|
34
|
+
false, // mdast value does not have ### prefixes
|
|
35
|
+
);
|
|
36
|
+
expect(isNextStepsHeading(makeHeading('Next steps'))).toBe(true);
|
|
37
|
+
expect(isNextStepsHeading(makeHeading('How to use them'))).toBe(true);
|
|
38
|
+
expect(isNextStepsHeading(makeHeading('How to use these tools'))).toBe(
|
|
39
|
+
true,
|
|
40
|
+
);
|
|
41
|
+
expect(isNextStepsHeading(makeHeading('Overview'))).toBe(false);
|
|
42
|
+
expect(isNextStepsHeading(makeHeading('Documents consulted'))).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('remarkCcaNextSteps plugin', () => {
|
|
47
|
+
it('leaves trees without matching headings untouched', () => {
|
|
48
|
+
const tree = {
|
|
49
|
+
type: 'root',
|
|
50
|
+
children: [
|
|
51
|
+
{
|
|
52
|
+
type: 'paragraph',
|
|
53
|
+
children: [{ type: 'text', value: 'Regular paragraph' }],
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
};
|
|
57
|
+
const result = applyPlugin(tree);
|
|
58
|
+
expect(result).toBe(tree);
|
|
59
|
+
expect(result.children).toHaveLength(1);
|
|
60
|
+
expect(result.children[0].type).toBe('paragraph');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('transforms heading and following ordered list into ccaNextSteps container', () => {
|
|
64
|
+
const tree = {
|
|
65
|
+
type: 'root',
|
|
66
|
+
children: [
|
|
67
|
+
{
|
|
68
|
+
type: 'paragraph',
|
|
69
|
+
children: [{ type: 'text', value: 'Here is an introduction.' }],
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
type: 'heading',
|
|
73
|
+
depth: 3,
|
|
74
|
+
children: [{ type: 'text', value: 'Suggested next steps' }],
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
type: 'list',
|
|
78
|
+
ordered: true,
|
|
79
|
+
children: [
|
|
80
|
+
{
|
|
81
|
+
type: 'listItem',
|
|
82
|
+
children: [
|
|
83
|
+
{
|
|
84
|
+
type: 'paragraph',
|
|
85
|
+
children: [
|
|
86
|
+
{ type: 'text', value: 'Run a hazard screening.' },
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
type: 'heading',
|
|
95
|
+
depth: 2,
|
|
96
|
+
children: [{ type: 'text', value: 'Limitations' }],
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const result = applyPlugin(tree);
|
|
102
|
+
expect(result.children).toHaveLength(3);
|
|
103
|
+
expect(result.children[0].type).toBe('paragraph');
|
|
104
|
+
|
|
105
|
+
const nextStepsNode = result.children[1];
|
|
106
|
+
expect(nextStepsNode.type).toBe('ccaNextSteps');
|
|
107
|
+
expect(nextStepsNode.data).toEqual({
|
|
108
|
+
hName: 'cca-suggested-next-steps',
|
|
109
|
+
hProperties: { title: 'Suggested next steps' },
|
|
110
|
+
});
|
|
111
|
+
expect(nextStepsNode.children).toHaveLength(1);
|
|
112
|
+
expect(nextStepsNode.children[0].type).toBe('list');
|
|
113
|
+
|
|
114
|
+
expect(result.children[2].type).toBe('heading');
|
|
115
|
+
expect(getNodeText(result.children[2])).toBe('Limitations');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('stops collecting siblings at thematicBreak or ccaDocCard', () => {
|
|
119
|
+
const tree = {
|
|
120
|
+
type: 'root',
|
|
121
|
+
children: [
|
|
122
|
+
{
|
|
123
|
+
type: 'heading',
|
|
124
|
+
depth: 2,
|
|
125
|
+
children: [{ type: 'text', value: 'How to use them:' }],
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
type: 'list',
|
|
129
|
+
ordered: true,
|
|
130
|
+
children: [],
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
type: 'thematicBreak',
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
type: 'ccaDocCard',
|
|
137
|
+
value: 'Some Card',
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const result = applyPlugin(tree);
|
|
143
|
+
expect(result.children).toHaveLength(3);
|
|
144
|
+
expect(result.children[0].type).toBe('ccaNextSteps');
|
|
145
|
+
expect(result.children[0].data.hProperties.title).toBe('How to use them');
|
|
146
|
+
expect(result.children[0].children).toHaveLength(1); // list only
|
|
147
|
+
expect(result.children[1].type).toBe('thematicBreak');
|
|
148
|
+
expect(result.children[2].type).toBe('ccaDocCard');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('handles heading with no following siblings gracefully', () => {
|
|
152
|
+
const tree = {
|
|
153
|
+
type: 'root',
|
|
154
|
+
children: [
|
|
155
|
+
{
|
|
156
|
+
type: 'heading',
|
|
157
|
+
depth: 3,
|
|
158
|
+
children: [{ type: 'text', value: 'Suggested next steps' }],
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const result = applyPlugin(tree);
|
|
164
|
+
expect(result.children).toHaveLength(1);
|
|
165
|
+
expect(result.children[0].type).toBe('ccaNextSteps');
|
|
166
|
+
expect(result.children[0].children).toHaveLength(0);
|
|
167
|
+
});
|
|
168
|
+
});
|