@raketa-cloud/admin 1.10.12 → 1.10.13

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 (40) hide show
  1. package/dist/application.css +699 -244
  2. package/package.json +4 -3
  3. package/src/cloud/Comments.js +88 -0
  4. package/src/cloud/Editors.js +20 -0
  5. package/src/cloud/documents/commentsDocuments.js +103 -0
  6. package/src/cloud/documents/editorsDocuments.js +27 -0
  7. package/src/handlers/resources/DiscussionsHandler.js +203 -0
  8. package/src/handlers/resources/HistoryHandler.js +7 -5
  9. package/src/handlers/resources/ResourcesHandler.js +6 -5
  10. package/src/helpers/commentMarkdown.js +94 -0
  11. package/src/helpers/fetchCommentCounts.js +14 -0
  12. package/src/helpers/historyUtils.js +11 -0
  13. package/src/helpers/utils.js +24 -0
  14. package/src/index.js +1 -0
  15. package/src/routeResourceModule.js +42 -0
  16. package/src/setupCloud.js +4 -0
  17. package/src/styles/application.css +56 -0
  18. package/src/tests/cloud/Comments.test.js +141 -0
  19. package/src/tests/cloud/Editors.test.js +47 -0
  20. package/src/tests/handlers/resources/DiscussionsHandler.test/create.test.js +108 -0
  21. package/src/tests/handlers/resources/DiscussionsHandler.test/destroy.test.js +55 -0
  22. package/src/tests/handlers/resources/DiscussionsHandler.test/index.test.js +195 -0
  23. package/src/tests/handlers/resources/DiscussionsHandler.test/update.test.js +87 -0
  24. package/src/tests/handlers/resources/HistoryHandler.test/index.test.js +9 -0
  25. package/src/tests/handlers/resources/ResourcesHandler.test/show.test.js +33 -0
  26. package/src/tests/handlers/resources/ResourcesHandler.test/show_reports.test.js +3 -0
  27. package/src/tests/helpers/commentMarkdown.test.js +166 -0
  28. package/src/tests/helpers/fetchCommentCounts.test.js +42 -0
  29. package/src/tests/helpers/historyUtils.test.js +26 -0
  30. package/src/tests/helpers/utils.test.js +21 -0
  31. package/src/tests/routeResourceModule.test.js +82 -2
  32. package/src/tests/setupCloud.test.js +4 -0
  33. package/src/tests/views/discussions.test.js +519 -0
  34. package/src/views/resources/_shared/_history_toolbar.ejs +10 -0
  35. package/src/views/resources/_shared/_tabs.ejs +21 -0
  36. package/src/views/resources/_shared/_toolbar.ejs +1 -1
  37. package/src/views/resources/discussions/_avatar.ejs +13 -0
  38. package/src/views/resources/discussions/_discussion_tabs.ejs +22 -0
  39. package/src/views/resources/discussions/index.ejs +729 -0
  40. package/src/views/resources/show.ejs +17 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raketa-cloud/admin",
3
- "version": "1.10.12",
3
+ "version": "1.10.13",
4
4
  "main": "src/index.js",
5
5
  "engines": {
6
6
  "node": ">=22"
@@ -26,7 +26,7 @@
26
26
  "@babel/preset-env": "7.29.0",
27
27
  "@babel/preset-typescript": "7.28.5",
28
28
  "@repo/typescript-config": "*",
29
- "@tailwindcss/cli": "4.3.3",
29
+ "@tailwindcss/cli": "4.3.0",
30
30
  "@tailwindcss/forms": "0.5.11",
31
31
  "@tailwindcss/typography": "0.5.20",
32
32
  "@types/node": "22.20.5",
@@ -43,7 +43,8 @@
43
43
  "google-auth-library": "11.3.1",
44
44
  "graphql-request": "7.4.0",
45
45
  "hono": "4.13.13",
46
- "lru-cache": "11.5.2"
46
+ "lru-cache": "11.5.2",
47
+ "markdown-it": "15.0.3"
47
48
  },
48
49
  "overrides": {
49
50
  "error-ex": "1.3.4"
@@ -0,0 +1,88 @@
1
+ import RaketaClient from "./RaketaClient";
2
+ import {
3
+ CreateResourceCommentDocument,
4
+ DeleteResourceCommentDocument,
5
+ ResourceCommentCountsDocument,
6
+ ResourceCommentsDocument,
7
+ UpdateResourceCommentDocument,
8
+ } from "./documents/commentsDocuments";
9
+
10
+ export default class Comments extends RaketaClient {
11
+ // Without a revision id, lists the general discussion of the resource.
12
+ async list(query, resourceId, locale, resourceType, revisionId = null) {
13
+ const { resourceComments: items } = await this.query(
14
+ ResourceCommentsDocument,
15
+ {
16
+ input: {
17
+ id: resourceId,
18
+ locale,
19
+ resourceType,
20
+ revisionId,
21
+ pagination: this.buildPaginationInput(query),
22
+ },
23
+ },
24
+ );
25
+
26
+ return items;
27
+ }
28
+
29
+ // The number of comments per discussion, keyed by revision id and "general"
30
+ // for the general discussion. Discussions without comments are missing.
31
+ async counts(resourceId, locale, resourceType) {
32
+ const { resourceCommentCounts } = await this.query(
33
+ ResourceCommentCountsDocument,
34
+ {
35
+ input: { id: resourceId, locale, resourceType },
36
+ },
37
+ );
38
+
39
+ return Object.fromEntries(
40
+ (resourceCommentCounts || []).map(({ revisionId, count }) => [
41
+ revisionId ?? "general",
42
+ count,
43
+ ]),
44
+ );
45
+ }
46
+
47
+ async create(resourceId, locale, resourceType, params) {
48
+ const {
49
+ createResourceComment: { comment, errors },
50
+ } = await this.query(CreateResourceCommentDocument, {
51
+ input: {
52
+ id: resourceId,
53
+ locale,
54
+ resourceType,
55
+ revisionId: params.revisionId || null,
56
+ comment: {
57
+ body: params.body,
58
+ replyId: params.replyId || null,
59
+ },
60
+ },
61
+ });
62
+
63
+ return { comment, errors };
64
+ }
65
+
66
+ async update(id, params) {
67
+ const {
68
+ updateResourceComment: { comment, errors },
69
+ } = await this.query(UpdateResourceCommentDocument, {
70
+ input: {
71
+ id,
72
+ comment: { body: params.body },
73
+ },
74
+ });
75
+
76
+ return { comment, errors };
77
+ }
78
+
79
+ async delete(id) {
80
+ const {
81
+ deleteResourceComment: { comment, errors },
82
+ } = await this.query(DeleteResourceCommentDocument, {
83
+ input: { id },
84
+ });
85
+
86
+ return { comment, errors };
87
+ }
88
+ }
@@ -0,0 +1,20 @@
1
+ import RaketaClient from "./RaketaClient";
2
+ import {
3
+ CurrentEditorDocument,
4
+ WebsiteEditorsDocument,
5
+ } from "./documents/editorsDocuments";
6
+
7
+ export default class Editors extends RaketaClient {
8
+ async current() {
9
+ const { currentEditor } = await this.query(CurrentEditorDocument);
10
+
11
+ return currentEditor;
12
+ }
13
+
14
+ // The active editors which are part of the current website.
15
+ async website() {
16
+ const { websiteEditors } = await this.query(WebsiteEditorsDocument);
17
+
18
+ return websiteEditors?.list || [];
19
+ }
20
+ }
@@ -0,0 +1,103 @@
1
+ import { gql } from "graphql-request";
2
+ import { errorsFragment, paginationFragment } from "./common";
3
+
4
+ function editorFragment() {
5
+ return gql`
6
+ {
7
+ id
8
+ name
9
+ email
10
+ avatar
11
+ }
12
+ `;
13
+ }
14
+
15
+ function mentionFragment() {
16
+ return gql`
17
+ {
18
+ editorId
19
+ name
20
+ email
21
+ }
22
+ `;
23
+ }
24
+
25
+ function commentFragment() {
26
+ return gql`
27
+ {
28
+ id
29
+ body
30
+ resourceId
31
+ revisionId
32
+ replyId
33
+ mentions ${mentionFragment()}
34
+ editor ${editorFragment()}
35
+ reply {
36
+ id
37
+ body
38
+ mentions ${mentionFragment()}
39
+ editor ${editorFragment()}
40
+ deletedAt
41
+ insertedAt
42
+ }
43
+ deletedAt
44
+ insertedAt
45
+ updatedAt
46
+ }
47
+ `;
48
+ }
49
+
50
+ const ResourceCommentsDocument = gql`
51
+ query ResourceComments($input: ResourceCommentsInput!) {
52
+ resourceComments(input: $input) {
53
+ list ${commentFragment()}
54
+ pagination ${paginationFragment()}
55
+ }
56
+ }
57
+ `;
58
+
59
+ const ResourceCommentCountsDocument = gql`
60
+ query ResourceCommentCounts($input: IdTypeInput!) {
61
+ resourceCommentCounts(input: $input) {
62
+ revisionId
63
+ count
64
+ }
65
+ }
66
+ `;
67
+
68
+ const CreateResourceCommentDocument = gql`
69
+ mutation CreateResourceComment($input: CreateResourceCommentInput!) {
70
+ createResourceComment(input: $input) {
71
+ comment ${commentFragment()}
72
+ errors ${errorsFragment()}
73
+ }
74
+ }
75
+ `;
76
+
77
+ const UpdateResourceCommentDocument = gql`
78
+ mutation UpdateResourceComment($input: UpdateResourceCommentInput!) {
79
+ updateResourceComment(input: $input) {
80
+ comment ${commentFragment()}
81
+ errors ${errorsFragment()}
82
+ }
83
+ }
84
+ `;
85
+
86
+ const DeleteResourceCommentDocument = gql`
87
+ mutation DeleteResourceComment($input: SimpleIdInput!) {
88
+ deleteResourceComment(input: $input) {
89
+ comment {
90
+ id
91
+ }
92
+ errors ${errorsFragment()}
93
+ }
94
+ }
95
+ `;
96
+
97
+ export {
98
+ CreateResourceCommentDocument,
99
+ DeleteResourceCommentDocument,
100
+ ResourceCommentCountsDocument,
101
+ ResourceCommentsDocument,
102
+ UpdateResourceCommentDocument,
103
+ };
@@ -0,0 +1,27 @@
1
+ import { gql } from "graphql-request";
2
+
3
+ const CurrentEditorDocument = gql`
4
+ query CurrentEditor {
5
+ currentEditor {
6
+ id
7
+ name
8
+ email
9
+ avatar
10
+ }
11
+ }
12
+ `;
13
+
14
+ const WebsiteEditorsDocument = gql`
15
+ query WebsiteEditors {
16
+ websiteEditors {
17
+ list {
18
+ id
19
+ name
20
+ email
21
+ avatar
22
+ }
23
+ }
24
+ }
25
+ `;
26
+
27
+ export { CurrentEditorDocument, WebsiteEditorsDocument };
@@ -0,0 +1,203 @@
1
+ import Logger from "../../Logger";
2
+
3
+ // The general discussion, or the one of a revision when given its id.
4
+ function discussionsPath(
5
+ ctx,
6
+ resourceId,
7
+ { locale = ctx.locale, revisionId = null } = {},
8
+ ) {
9
+ const path = `/${locale}/${ctx.resourceConfig.resourceTypePlural}/${resourceId}/discussions`;
10
+
11
+ return revisionId ? `${path}/${revisionId}` : path;
12
+ }
13
+
14
+ function parseRevisionId(value) {
15
+ if (value === undefined || value === null || value === "") return null;
16
+ if (/^\d+$/.test(String(value))) return String(value);
17
+
18
+ throw new Error(`Missing discussion for revision #${value}`);
19
+ }
20
+
21
+ const BLANK_ERROR = "Comment can't be blank.";
22
+
23
+ const ERROR_MESSAGES = {
24
+ revisionId: "This revision was published, its discussion is read-only.",
25
+ };
26
+
27
+ function errorPath(path, message) {
28
+ return `${path}?comment_error=${encodeURIComponent(message)}`;
29
+ }
30
+
31
+ function errorsMessage(errors) {
32
+ return errors
33
+ .map((error) => ERROR_MESSAGES[error.key] || error.message)
34
+ .join(", ");
35
+ }
36
+
37
+ // The login token may hold a stale or missing avatar, while the DXP editor is
38
+ // kept in sync with the profile, so prefer it.
39
+ async function fetchCurrentEditor(ctx) {
40
+ const fallback = ctx.authentication?.user || {};
41
+
42
+ try {
43
+ return (await ctx.client.editors.current()) || fallback;
44
+ } catch (e) {
45
+ Logger.error(`Failed to fetch the current editor: ${e.message}`);
46
+ return fallback;
47
+ }
48
+ }
49
+
50
+ // The editors which can be mentioned. The discussion works without them.
51
+ async function fetchWebsiteEditors(ctx) {
52
+ try {
53
+ return await ctx.client.editors.website();
54
+ } catch (e) {
55
+ Logger.error(`Failed to fetch the website editors: ${e.message}`);
56
+ return [];
57
+ }
58
+ }
59
+
60
+ const index = async (ctx) => {
61
+ const { parent_id } = ctx.params;
62
+ const { resourceType } = ctx.resourceConfig;
63
+
64
+ const resource = await ctx.client.resources.find(
65
+ parent_id,
66
+ ctx.locale,
67
+ resourceType,
68
+ );
69
+
70
+ if (!resource) {
71
+ throw new Error(`Missing ${resourceType} #${parent_id}`);
72
+ }
73
+
74
+ const revisionId = parseRevisionId(ctx.params.revision_id);
75
+
76
+ const [comments, currentEditor, editors, revisions] = await Promise.all([
77
+ ctx.client.comments.list(
78
+ ctx.query,
79
+ parent_id,
80
+ ctx.locale,
81
+ resourceType,
82
+ revisionId,
83
+ ),
84
+ fetchCurrentEditor(ctx),
85
+ fetchWebsiteEditors(ctx),
86
+ ctx.client.resourceRevisions.all(parent_id, ctx.locale, resourceType),
87
+ ]);
88
+
89
+ const openRevisions = revisions?.list || [];
90
+ const revision = revisionId
91
+ ? openRevisions.find((item) => String(item.id) === revisionId) || null
92
+ : null;
93
+
94
+ return {
95
+ assigns: {
96
+ resource,
97
+ comments,
98
+ currentEditor,
99
+ editors,
100
+ revisions: openRevisions,
101
+ // A revision which is no longer open was published, its discussion is
102
+ // read-only.
103
+ discussion: {
104
+ revisionId,
105
+ revision,
106
+ archived: Boolean(revisionId && !revision),
107
+ path: discussionsPath(ctx, parent_id, { revisionId }),
108
+ },
109
+ _translations: Object.keys(ctx.locales)
110
+ .map((locale) => {
111
+ const translation = resource.translations[locale];
112
+
113
+ if (translation) {
114
+ return {
115
+ locale,
116
+ path: discussionsPath(ctx, translation.id, { locale }),
117
+ };
118
+ }
119
+
120
+ return null;
121
+ })
122
+ .filter(Boolean),
123
+ },
124
+ };
125
+ };
126
+
127
+ const create = async (ctx) => {
128
+ const { parent_id } = ctx.params;
129
+ const params = ctx.resourceParams.comment || {};
130
+ const body = (params.body || "").trim();
131
+ const revisionId = parseRevisionId(ctx.params.revision_id);
132
+ const path = discussionsPath(ctx, parent_id, { revisionId });
133
+
134
+ if (!body) {
135
+ return { redirect: errorPath(path, BLANK_ERROR) };
136
+ }
137
+
138
+ const { errors } = await ctx.client.comments.create(
139
+ parent_id,
140
+ ctx.locale,
141
+ ctx.resourceConfig.resourceType,
142
+ { body, replyId: params.reply_id, revisionId },
143
+ );
144
+
145
+ if (errors) {
146
+ return { redirect: errorPath(path, errorsMessage(errors)) };
147
+ }
148
+
149
+ return {
150
+ redirect: path,
151
+ };
152
+ };
153
+
154
+ // Submitted by htmx from the inline edit form, so it redirects with headers.
155
+ const update = async (ctx) => {
156
+ const { id, parent_id } = ctx.params;
157
+ const params = ctx.resourceParams.comment || {};
158
+ const body = (params.body || "").trim();
159
+ const path = discussionsPath(ctx, parent_id, {
160
+ revisionId: parseRevisionId(ctx.resourceParams.revision_id),
161
+ });
162
+
163
+ const respond = (location) => ({
164
+ headers: { "HX-Redirect": location },
165
+ json: {},
166
+ });
167
+
168
+ if (!body) {
169
+ return respond(errorPath(path, BLANK_ERROR));
170
+ }
171
+
172
+ const { errors } = await ctx.client.comments.update(id, { body });
173
+
174
+ if (errors) {
175
+ return respond(errorPath(path, errorsMessage(errors)));
176
+ }
177
+
178
+ return respond(path);
179
+ };
180
+
181
+ const destroy = async (ctx) => {
182
+ const { id } = ctx.params;
183
+
184
+ const { errors } = await ctx.client.comments.delete(id);
185
+
186
+ if (errors) {
187
+ throw `Failed to delete comment #${id}: ${errorsMessage(errors)}`;
188
+ }
189
+
190
+ return {
191
+ headers: {
192
+ "HX-Refresh": "true",
193
+ },
194
+ json: {},
195
+ };
196
+ };
197
+
198
+ export default {
199
+ index,
200
+ create,
201
+ update,
202
+ destroy,
203
+ };
@@ -1,3 +1,5 @@
1
+ import fetchCommentCounts from "../../helpers/fetchCommentCounts";
2
+
1
3
  const index = async (ctx) => {
2
4
  const { parent_id } = ctx.params;
3
5
  const { resourceType } = ctx.resourceConfig;
@@ -19,17 +21,17 @@ const index = async (ctx) => {
19
21
  resourceType,
20
22
  );
21
23
 
22
- const editors = await ctx.client.resourceChanges.all_editors(
23
- parent_id,
24
- ctx.locale,
25
- resourceType,
26
- );
24
+ const [editors, commentCounts] = await Promise.all([
25
+ ctx.client.resourceChanges.all_editors(parent_id, ctx.locale, resourceType),
26
+ fetchCommentCounts(ctx, parent_id, resourceType),
27
+ ]);
27
28
 
28
29
  return {
29
30
  assigns: {
30
31
  resource,
31
32
  changes: changes,
32
33
  editors,
34
+ commentCounts,
33
35
  },
34
36
  };
35
37
  };
@@ -1,3 +1,4 @@
1
+ import fetchCommentCounts from "../../helpers/fetchCommentCounts";
1
2
  import { CWV_METRICS } from "../../helpers/reports/cwv";
2
3
  import { reportsAvailability } from "../../helpers/reports/reportsUtils";
3
4
 
@@ -41,11 +42,10 @@ const show = async (ctx) => {
41
42
  throw new Error(`Missing ${resourceType} #${id}`);
42
43
  }
43
44
 
44
- const revisions = await ctx.client.resourceRevisions.all(
45
- id,
46
- ctx.locale,
47
- resourceType,
48
- );
45
+ const [revisions, commentCounts] = await Promise.all([
46
+ ctx.client.resourceRevisions.all(id, ctx.locale, resourceType),
47
+ fetchCommentCounts(ctx, id, resourceType),
48
+ ]);
49
49
 
50
50
  const changes = await ctx.client.resourceChanges.list(
51
51
  {
@@ -78,6 +78,7 @@ const show = async (ctx) => {
78
78
  vitalsMeta: available.webVitals ? CWV_METRICS : [],
79
79
  },
80
80
  revisions: revisions.list,
81
+ commentCounts,
81
82
  changes: changes,
82
83
  _translations: Object.keys(ctx.locales)
83
84
  .map((locale) => {
@@ -0,0 +1,94 @@
1
+ import MarkdownIt from "markdown-it";
2
+
3
+ // Comments support only bold, italics, lists, quotes and links. Links are
4
+ // detected in the text, the markdown link syntax stays plain text. Raw HTML is
5
+ // escaped, so the output is safe to render unescaped.
6
+ const md = new MarkdownIt("zero", {
7
+ html: false,
8
+ linkify: true,
9
+ breaks: true,
10
+ }).enable(["emphasis", "list", "blockquote", "newline", "linkify"]);
11
+
12
+ md.linkify.set({ fuzzyLink: true, fuzzyEmail: false });
13
+
14
+ // Bare domains are linked only for known TLDs. The defaults miss the newer
15
+ // generic ones, URLs with a protocol are linked regardless.
16
+ md.linkify.tlds(
17
+ [
18
+ "agency",
19
+ "app",
20
+ "blog",
21
+ "cloud",
22
+ "design",
23
+ "dev",
24
+ "digital",
25
+ "online",
26
+ "site",
27
+ "store",
28
+ "studio",
29
+ "tech",
30
+ ],
31
+ true,
32
+ );
33
+
34
+ md.renderer.rules.link_open = (tokens, idx, options, _env, self) => {
35
+ tokens[idx].attrSet("target", "_blank");
36
+ tokens[idx].attrSet("rel", "noopener noreferrer");
37
+
38
+ return self.renderToken(tokens, idx, options);
39
+ };
40
+
41
+ // Editors are mentioned with "@editor-<id>" in the stored text. The comments
42
+ // carry the names of their mentioned editors.
43
+ const MENTION_PATTERN = /(?<![\p{L}\p{N}_@/])@editor-(\d+)(?![\p{L}\p{N}_])/gu;
44
+
45
+ const mentionName = (mention) =>
46
+ (mention.name || "").trim() || mention.email || "";
47
+
48
+ function mentionNames(mentions) {
49
+ return new Map(
50
+ (mentions || [])
51
+ .filter((mention) => mentionName(mention))
52
+ .map((mention) => [String(mention.editorId), mentionName(mention)]),
53
+ );
54
+ }
55
+
56
+ md.renderer.rules.text = (tokens, idx, _options, env) => {
57
+ const content = tokens[idx].content;
58
+ const escape = md.utils.escapeHtml;
59
+ let html = "";
60
+ let last = 0;
61
+
62
+ for (const match of content.matchAll(MENTION_PATTERN)) {
63
+ const name = env.mentionNames?.get(match[1]);
64
+
65
+ html += escape(content.slice(last, match.index));
66
+ html += name
67
+ ? `<span class="comment-mention">@${escape(name)}</span>`
68
+ : '<span class="comment-mention-unknown">@Unknown editor</span>';
69
+ last = match.index + match[0].length;
70
+ }
71
+
72
+ return html + escape(content.slice(last));
73
+ };
74
+
75
+ // The text with the known mentions written as "@Name", for previews and for
76
+ // editing. Unknown mentions stay as they are.
77
+ export function commentMentionsText(text, mentions = []) {
78
+ const names = mentionNames(mentions);
79
+
80
+ return (text || "").replace(MENTION_PATTERN, (token, id) =>
81
+ names.has(id) ? `@${names.get(id)}` : token,
82
+ );
83
+ }
84
+
85
+ // The editor ids of the mentions by their names, for editing.
86
+ export function commentMentionIds(mentions = []) {
87
+ return Object.fromEntries(
88
+ [...mentionNames(mentions)].map(([id, name]) => [name, id]),
89
+ );
90
+ }
91
+
92
+ export default function commentMarkdown(text, { mentions = [] } = {}) {
93
+ return md.render(text || "", { mentionNames: mentionNames(mentions) });
94
+ }
@@ -0,0 +1,14 @@
1
+ import Logger from "../Logger";
2
+
3
+ // The comment counts only decorate the revisions and history tables, so the
4
+ // pages render without them when they cannot be fetched.
5
+ export default async function fetchCommentCounts(ctx, id, resourceType) {
6
+ if (!ctx.resourceConfig?.comments) return {};
7
+
8
+ try {
9
+ return await ctx.client.comments.counts(id, ctx.locale, resourceType);
10
+ } catch (e) {
11
+ Logger.error(`Failed to fetch the comment counts: ${e.message}`);
12
+ return {};
13
+ }
14
+ }
@@ -124,8 +124,19 @@ function isChangeRevertable(change, maxVersion) {
124
124
  );
125
125
  }
126
126
 
127
+ // Published revisions keep their discussions, which can be opened from the
128
+ // history when they have comments.
129
+ function hasDiscussion(change, commentCounts = {}) {
130
+ return (
131
+ change.changeType === "revision_publish" &&
132
+ Boolean(change.revisionId) &&
133
+ (commentCounts?.[change.revisionId] || 0) > 0
134
+ );
135
+ }
136
+
127
137
  export default {
128
138
  changeTypes,
139
+ hasDiscussion,
129
140
  getMaxVersion,
130
141
  changeAction,
131
142
  isChangeRevertable,
@@ -1,3 +1,8 @@
1
+ import commentMarkdown, {
2
+ commentMentionIds,
3
+ commentMentionsText,
4
+ } from "./commentMarkdown";
5
+
1
6
  function includedInCollection(value, collection) {
2
7
  if (!collection) {
3
8
  return false;
@@ -43,6 +48,21 @@ export function isFeatureEnabled(value) {
43
48
  return value === true;
44
49
  }
45
50
 
51
+ // Initials of the first two words of the name, e.g. "Jane van Doe" -> "JV".
52
+ // Falls back to the first letter of the email when there is no name.
53
+ function initials(name, email = "") {
54
+ const words = (name || "").trim().split(/\s+/).filter(Boolean);
55
+
56
+ if (words.length) {
57
+ return words
58
+ .slice(0, 2)
59
+ .map((word) => word[0].toUpperCase())
60
+ .join("");
61
+ }
62
+
63
+ return (email || "?").trim().charAt(0).toUpperCase() || "?";
64
+ }
65
+
46
66
  function feature(featureName, context) {
47
67
  return isFeatureEnabled(context?.globalConfig?.features?.[featureName]);
48
68
  }
@@ -52,9 +72,13 @@ function contentTaskProviderEnabled(provider, context) {
52
72
  }
53
73
 
54
74
  export default {
75
+ commentMarkdown,
76
+ commentMentionIds,
77
+ commentMentionsText,
55
78
  includedInCollection,
56
79
  formattedDate,
57
80
  formattedDateTime,
58
81
  feature,
59
82
  contentTaskProviderEnabled,
83
+ initials,
60
84
  };