@pushwoosh/smart-components-kit 1.3.19 → 1.3.20

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.
@@ -3,12 +3,13 @@ import { useState } from 'react';
3
3
  import { Badge, Button } from '@pushwoosh/dumb-components';
4
4
  import { Color } from '@pushwoosh/kit-constants';
5
5
  import { Cluster, Columns, Horizontal, Vertical } from '@pushwoosh/kit-helpers';
6
- import { Paragraph, Text } from '@pushwoosh/kit-typography';
6
+ import { Text } from '@pushwoosh/kit-typography';
7
7
  import { useSPAContext } from '@pushwoosh/spa-context';
8
8
  import FallbackImage from './assets/fallback.svg?url';
9
9
  import { ALERT_STATUS_UNREAD } from './constants';
10
10
  import { formatUpdateDate, openActionLink } from './helpers';
11
11
  import { useLang } from './polyglot';
12
+ import { renderContent } from './renderContent';
12
13
  import { CardImage } from './styles';
13
14
  export function ProductUpdateCard({
14
15
  update,
@@ -57,9 +58,7 @@ export function ProductUpdateCard({
57
58
  children: [_jsx(Text, {
58
59
  "$variant": "h4",
59
60
  children: update.title
60
- }), update.content && _jsx(Paragraph, {
61
- children: update.content
62
- })]
61
+ }), update.content && renderContent(update.content)]
63
62
  })]
64
63
  }), (update.actionLink || update.secondaryActionLink) && _jsxs(Cluster, {
65
64
  "$gap": 8,
@@ -0,0 +1,15 @@
1
+ export type InlineNode = {
2
+ readonly id: string;
3
+ readonly type: 'text' | 'bold';
4
+ readonly value: string;
5
+ };
6
+ export type ContentLine = {
7
+ readonly id: string;
8
+ readonly nodes: readonly InlineNode[];
9
+ };
10
+ export type ContentBlock = {
11
+ readonly id: string;
12
+ readonly type: 'paragraph' | 'list';
13
+ readonly lines: readonly ContentLine[];
14
+ };
15
+ export declare function parseContent(content: string): ContentBlock[];
@@ -0,0 +1,66 @@
1
+ const PARAGRAPH_BREAK = /\n{2,}/;
2
+ const BULLET = /^-\s+/;
3
+ const BOLD = /\*\*([\s\S]+?)\*\*/g;
4
+ // Announcements carry three constructs and nothing else: an unmatched `**`, a lone `*`
5
+ // and any HTML stay literal text, so authors cannot break the card with a typo.
6
+ function parseInline(raw, keyPrefix) {
7
+ const nodes = [];
8
+ let cursor = 0;
9
+ const push = (type, value) => {
10
+ nodes.push({
11
+ id: `${keyPrefix}-n${nodes.length}`,
12
+ type,
13
+ value
14
+ });
15
+ };
16
+ Array.from(raw.matchAll(BOLD)).forEach(match => {
17
+ const start = match.index ?? 0;
18
+ if (start > cursor) {
19
+ push('text', raw.slice(cursor, start));
20
+ }
21
+ push('bold', match[1]);
22
+ cursor = start + match[0].length;
23
+ });
24
+ if (cursor < raw.length) {
25
+ push('text', raw.slice(cursor));
26
+ }
27
+ return nodes;
28
+ }
29
+ export function parseContent(content) {
30
+ const blocks = [];
31
+ const flush = (type, rawLines) => {
32
+ if (rawLines.length === 0) {
33
+ return;
34
+ }
35
+ const id = `b${blocks.length}`;
36
+ blocks.push({
37
+ id,
38
+ type,
39
+ lines: rawLines.map((line, index) => ({
40
+ id: `${id}-l${index}`,
41
+ nodes: parseInline(line, `${id}-l${index}`)
42
+ }))
43
+ });
44
+ };
45
+ content.split(PARAGRAPH_BREAK).forEach(rawBlock => {
46
+ const lines = rawBlock.split('\n').map(line => line.trim()).filter(line => line.length > 0);
47
+ let pendingType = null;
48
+ let pending = [];
49
+ lines.forEach(line => {
50
+ const isBullet = BULLET.test(line);
51
+ const type = isBullet ? 'list' : 'paragraph';
52
+ // A bullet run right after a text line is its own block: the author wrote a lead-in
53
+ // and a list, not one paragraph, and a blank line between them should not be required.
54
+ if (pendingType !== null && pendingType !== type) {
55
+ flush(pendingType, pending);
56
+ pending = [];
57
+ }
58
+ pendingType = type;
59
+ pending.push(isBullet ? line.replace(BULLET, '') : line);
60
+ });
61
+ if (pendingType !== null) {
62
+ flush(pendingType, pending);
63
+ }
64
+ });
65
+ return blocks;
66
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,59 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { parseContent } from './parseContent';
3
+ // Compact projection: `**bold**` becomes [bold] so a case reads as one line.
4
+ function sketch(blocks) {
5
+ return blocks.map(block => {
6
+ const lines = block.lines.map(line => line.nodes.map(node => node.type === 'bold' ? `[${node.value}]` : node.value).join(''));
7
+ return block.type === 'list' ? `ul: ${lines.join(' | ')}` : `p: ${lines.join(' / ')}`;
8
+ });
9
+ }
10
+ describe('parseContent', () => {
11
+ it('splits paragraphs on a blank line', () => {
12
+ expect(sketch(parseContent('first\n\nsecond'))).toEqual(['p: first', 'p: second']);
13
+ });
14
+ it('keeps a single newline inside one paragraph', () => {
15
+ expect(sketch(parseContent('first\nsecond'))).toEqual(['p: first / second']);
16
+ });
17
+ it('collects consecutive bullets into one list', () => {
18
+ expect(sketch(parseContent('- one\n- two\n- three'))).toEqual(['ul: one | two | three']);
19
+ });
20
+ it('reads bold inside a list item', () => {
21
+ expect(sketch(parseContent('- plain **loud**'))).toEqual(['ul: plain [loud]']);
22
+ });
23
+ it('separates a lead-in line from the bullets that follow it', () => {
24
+ expect(sketch(parseContent('what changed:\n- one\n- two\nand that is all'))).toEqual(['p: what changed:', 'ul: one | two', 'p: and that is all']);
25
+ });
26
+ it('treats three or more newlines as a single paragraph break', () => {
27
+ expect(sketch(parseContent('first\n\n\n\nsecond'))).toEqual(['p: first', 'p: second']);
28
+ });
29
+ it('ignores blank lines around the content', () => {
30
+ expect(sketch(parseContent('\n\n text \n\n'))).toEqual(['p: text']);
31
+ });
32
+ it('leaves a lone asterisk as text', () => {
33
+ expect(sketch(parseContent('2 * 3 = 6'))).toEqual(['p: 2 * 3 = 6']);
34
+ });
35
+ it('does not read `* ` as a bullet marker', () => {
36
+ expect(sketch(parseContent('* one\n* two'))).toEqual(['p: * one / * two']);
37
+ });
38
+ it('leaves an unclosed bold marker as text', () => {
39
+ expect(sketch(parseContent('**loud and then not'))).toEqual(['p: **loud and then not']);
40
+ });
41
+ it('leaves an empty bold marker as text', () => {
42
+ expect(sketch(parseContent('nothing **** here'))).toEqual(['p: nothing **** here']);
43
+ });
44
+ it('reads several bold runs in one line', () => {
45
+ expect(sketch(parseContent('**a** and **b**'))).toEqual(['p: [a] and [b]']);
46
+ });
47
+ it('leaves HTML as text', () => {
48
+ expect(sketch(parseContent('<b>test</b>'))).toEqual(['p: <b>test</b>']);
49
+ });
50
+ it('returns nothing for empty or blank content', () => {
51
+ expect(parseContent('')).toEqual([]);
52
+ expect(parseContent(' \n \n')).toEqual([]);
53
+ });
54
+ it('gives every block, line and node a unique id', () => {
55
+ const blocks = parseContent('lead **in**\n- one **two**\n- three\n\ntail');
56
+ const ids = blocks.flatMap(block => [block.id, ...block.lines.flatMap(line => [line.id, ...line.nodes.map(node => node.id)])]);
57
+ expect(new Set(ids).size).toBe(ids.length);
58
+ });
59
+ });
@@ -0,0 +1,2 @@
1
+ import { type ReactNode } from 'react';
2
+ export declare function renderContent(content: string): ReactNode;
@@ -0,0 +1,27 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Fragment } from 'react';
3
+ import { Paragraph } from '@pushwoosh/kit-typography';
4
+ import { parseContent } from './parseContent';
5
+ import { Bold, ContentBox, List } from './styles';
6
+ function renderLine(line) {
7
+ return line.nodes.map(node => node.type === 'bold' ? _jsx(Bold, {
8
+ children: node.value
9
+ }, node.id) : node.value);
10
+ }
11
+ export function renderContent(content) {
12
+ const blocks = parseContent(content);
13
+ if (blocks.length === 0) {
14
+ return null;
15
+ }
16
+ return _jsx(ContentBox, {
17
+ children: blocks.map(block => block.type === 'list' ? _jsx(List, {
18
+ children: block.lines.map(line => _jsx("li", {
19
+ children: renderLine(line)
20
+ }, line.id))
21
+ }, block.id) : _jsx(Paragraph, {
22
+ children: block.lines.map((line, index) => _jsxs(Fragment, {
23
+ children: [index > 0 && _jsx("br", {}), renderLine(line)]
24
+ }, line.id))
25
+ }, block.id))
26
+ });
27
+ }
@@ -1 +1,4 @@
1
1
  export declare const CardImage: import("styled-components").StyledComponent<"img", any, {}, never>;
2
+ export declare const ContentBox: import("styled-components").StyledComponent<"div", any, {}, never>;
3
+ export declare const List: import("styled-components").StyledComponent<"ul", any, {}, never>;
4
+ export declare const Bold: import("styled-components").StyledComponent<"strong", any, {}, never>;
@@ -1,8 +1,22 @@
1
1
  import styled from 'styled-components';
2
- import { ShapeRadius } from '@pushwoosh/kit-constants';
2
+ import { FontWeight, ShapeRadius } from '@pushwoosh/kit-constants';
3
3
  // The only rule kit-helpers cannot express: object-fit has no prop there, and it is
4
4
  // what keeps a wrongly proportioned upload from stretching the card.
5
5
  export const CardImage = styled.img.withConfig({
6
6
  displayName: "CardImage",
7
7
  componentId: "sc-11vonu6-0"
8
- })(["width:256px;height:160px;object-fit:cover;border-radius:", ";display:block;"], ShapeRadius.SECTION);
8
+ })(["width:256px;height:160px;object-fit:cover;border-radius:", ";display:block;"], ShapeRadius.SECTION);
9
+ // Paragraph's own `& + &` rule only matches a <p> after a <p>, so the gap around a list
10
+ // has to live on the wrapper both block kinds share.
11
+ export const ContentBox = styled.div.withConfig({
12
+ displayName: "ContentBox",
13
+ componentId: "sc-11vonu6-1"
14
+ })(["> * + *{margin-top:0.5em;}"]);
15
+ export const List = styled.ul.withConfig({
16
+ displayName: "List",
17
+ componentId: "sc-11vonu6-2"
18
+ })(["margin:0;padding-left:1.25em;list-style:disc outside;li + li{margin-top:0.25em;}"]);
19
+ export const Bold = styled.strong.withConfig({
20
+ displayName: "Bold",
21
+ componentId: "sc-11vonu6-3"
22
+ })(["font-weight:", ";"], FontWeight.BOLD);
package/package.json CHANGED
@@ -12,10 +12,11 @@
12
12
  "dev": "`pushwoosh-engine app:command:dev`",
13
13
  "build": "pushwoosh-engine lib:build",
14
14
  "playground": "`pushwoosh-engine lib:command:playground`",
15
- "check": "npm run check:lint && npm run check:types",
15
+ "check": "npm run check:lint && npm run check:types && npm run test",
16
16
  "check:lint": "echo \"Run check:lint\" && NODE_ENV=production eslint src",
17
17
  "check:lint-fix": "echo \"Run check:lint\" && NODE_ENV=production eslint src --fix",
18
- "check:types": "echo \"Run check:types\" && pushwoosh-engine lib:check-types"
18
+ "check:types": "echo \"Run check:types\" && pushwoosh-engine lib:check-types",
19
+ "test": "echo \"Run test\" && vitest run"
19
20
  },
20
21
  "pre-commit": "check",
21
22
  "engines": {
@@ -46,11 +47,12 @@
46
47
  "pre-commit": "^1.2.2",
47
48
  "react": "^18.3.1",
48
49
  "react-dom": "^18.3.1",
49
- "styled-components": "^5.3.11"
50
+ "styled-components": "^5.3.11",
51
+ "vitest": "^3.2.4"
50
52
  },
51
53
  "peerDependencies": {
52
54
  "react": "^16.14.0 || ^18.0.0",
53
55
  "react-dom": "^16.14.0 || ^18.0.0"
54
56
  },
55
- "version": "1.3.19"
57
+ "version": "1.3.20"
56
58
  }