@cntrl-site/sdk-nextjs 1.10.8 → 1.11.0-0

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 (42) hide show
  1. package/cntrl-site-sdk-nextjs-1.9.125.tgz +0 -0
  2. package/lib/components/StructuredBlockItem/BlockCaption/BlockCaption.js +35 -0
  3. package/lib/components/StructuredBlockItem/StructuredBlockItem.js +17 -42
  4. package/lib/components/StructuredBlockItem/StructuredCode/StructuredCode.js +75 -0
  5. package/lib/components/StructuredBlockItem/StructuredDivider/StructuredDivider.js +29 -0
  6. package/lib/components/StructuredBlockItem/StructuredEmbed/StructuredVimeoEmbed.js +89 -0
  7. package/lib/components/StructuredBlockItem/StructuredEmbed/StructuredYoutubeEmbed.js +97 -0
  8. package/lib/components/StructuredBlockItem/StructuredEmbed/embedFrameStyles.js +36 -0
  9. package/lib/components/StructuredBlockItem/StructuredHeaderImage/StructuredHeaderImage.js +43 -0
  10. package/lib/components/StructuredBlockItem/StructuredImage/StructuredImage.js +65 -0
  11. package/lib/components/StructuredBlockItem/StructuredRichText/StructuredRichText.js +46 -0
  12. package/lib/components/StructuredBlockItem/StructuredVideo/StructuredVideo.js +74 -0
  13. package/lib/components/StructuredBlockItem/blockPlacement.js +41 -0
  14. package/lib/components/StructuredBlockItem/blocksMap.js +29 -0
  15. package/lib/components/StructuredBlockItem/textStylesRule.js +46 -0
  16. package/lib/components/StructuredContent/StructuredContent.js +24 -17
  17. package/lib/components/StructuredContent/StructuredHeader.js +95 -0
  18. package/lib/components/items/EmbedVideoItem/VimeoEmbed.js +2 -15
  19. package/lib/utils/RichTextConverter/RichTextConverter.js +52 -14
  20. package/lib/utils/getVimeoEmbedUrl.js +18 -0
  21. package/package.json +3 -3
  22. package/src/components/StructuredBlockItem/BlockCaption/BlockCaption.tsx +42 -0
  23. package/src/components/StructuredBlockItem/StructuredBlockItem.tsx +23 -50
  24. package/src/components/StructuredBlockItem/StructuredCode/StructuredCode.tsx +86 -0
  25. package/src/components/StructuredBlockItem/StructuredDivider/StructuredDivider.tsx +31 -0
  26. package/src/components/StructuredBlockItem/StructuredEmbed/StructuredVimeoEmbed.tsx +101 -0
  27. package/src/components/StructuredBlockItem/StructuredEmbed/StructuredYoutubeEmbed.tsx +106 -0
  28. package/src/components/StructuredBlockItem/StructuredEmbed/embedFrameStyles.ts +32 -0
  29. package/src/components/StructuredBlockItem/StructuredHeaderImage/StructuredHeaderImage.tsx +43 -0
  30. package/src/components/StructuredBlockItem/StructuredImage/StructuredImage.tsx +75 -0
  31. package/src/components/StructuredBlockItem/StructuredRichText/StructuredRichText.tsx +53 -0
  32. package/src/components/StructuredBlockItem/StructuredVideo/StructuredVideo.tsx +106 -0
  33. package/src/components/StructuredBlockItem/blockPlacement.ts +39 -0
  34. package/src/components/StructuredBlockItem/blocksMap.ts +30 -0
  35. package/src/components/StructuredBlockItem/textStylesRule.ts +44 -0
  36. package/src/components/StructuredContent/StructuredContent.test.tsx +217 -0
  37. package/src/components/StructuredContent/StructuredContent.tsx +25 -19
  38. package/src/components/StructuredContent/StructuredHeader.tsx +119 -0
  39. package/src/components/items/EmbedVideoItem/VimeoEmbed.tsx +2 -14
  40. package/src/utils/RichTextConverter/RichTextConverter.test.tsx +67 -0
  41. package/src/utils/RichTextConverter/RichTextConverter.tsx +77 -14
  42. package/src/utils/getVimeoEmbedUrl.ts +16 -0
@@ -0,0 +1,67 @@
1
+ import { render } from '@testing-library/react';
2
+ import { Layout, TextAlign } from '@cntrl-site/sdk';
3
+ import { RICH_TEXT_LAYOUT_PENDING_CLASS, RichTextConverter, RichTextSource } from './RichTextConverter';
4
+
5
+ const layouts: Layout[] = [{ id: 'desktop', title: 'Desktop', startsWith: 800, exemplary: 1440 }];
6
+
7
+ function text(blocks: { text: string; type?: string }[], rangeStyles = [{ start: 0, end: 4, style: 'FONTSIZE', value: '0.02' }]): RichTextSource {
8
+ let offset = 0;
9
+ const richTextBlocks = blocks.map(({ text, type = 'unstyled' }) => {
10
+ const block = { start: offset, end: offset + text.length, type, entities: [] };
11
+ offset += text.length + 1;
12
+ return block;
13
+ });
14
+ return {
15
+ id: 'text',
16
+ commonParams: { text: blocks.map(block => `${block.text}\n`).join(''), blocks: richTextBlocks },
17
+ layoutParams: { desktop: { rangeStyles, textAlign: TextAlign.Left } }
18
+ };
19
+ }
20
+
21
+ describe('RichTextConverter', () => {
22
+ const converter = new RichTextConverter();
23
+
24
+ it('sizes an item\'s ranges in pixels of the exemplary, in viewport units while the layout is not known', () => {
25
+ const [, styles] = converter.toHtml(text([{ text: 'Read on' }]), layouts);
26
+ expect(styles).toContain('font-size: 28.8px');
27
+ expect(styles).toContain(`.${RICH_TEXT_LAYOUT_PENDING_CLASS}`);
28
+ });
29
+
30
+ it('sizes the ranges of text in the page\'s flow in viewport units throughout', () => {
31
+ const [, styles] = converter.toHtml(text([{ text: 'Read on' }]), layouts, { isScaledToViewport: false });
32
+ expect(styles).toContain('font-size: 2vw');
33
+ expect(styles).not.toMatch(/font-size: [\d.]+px/);
34
+ expect(styles).not.toContain(RICH_TEXT_LAYOUT_PENDING_CLASS);
35
+ });
36
+
37
+ it('draws list items with their markers, a numbered run restarting after anything else', () => {
38
+ const [content, styles] = converter.toHtml(text([
39
+ { text: 'Intro' },
40
+ { text: 'One', type: 'ordered-list-item' },
41
+ { text: 'Two', type: 'ordered-list-item' },
42
+ { text: 'Dot', type: 'unordered-list-item' }
43
+ ], []), layouts, { isScaledToViewport: false });
44
+ const { container } = render(<>{content}</>);
45
+ expect([...container.children].map(child => child.className.split(' ')[1])).toEqual([
46
+ 'rt-paragraph',
47
+ 'rt-ordered-list-item',
48
+ 'rt-ordered-list-item',
49
+ 'rt-unordered-list-item'
50
+ ]);
51
+ expect(styles).toContain('counter-increment: cntrl-list');
52
+ expect(styles).toContain('content: \'•\'');
53
+ });
54
+
55
+ it('draws a highlighted range on its colour, with an rgba fallback where oklch is not supported', () => {
56
+ const [, styles] = converter.toHtml(text([{ text: 'Read on' }], [{ start: 0, end: 4, style: 'HIGHLIGHT', value: '#FFFF00' }]), layouts);
57
+ expect(styles).toMatch(/\.s-0-4 \{\s*background-color: [^;]+;/);
58
+ expect(styles).toContain('background-color: rgba(255, 255, 0, 1)');
59
+ });
60
+
61
+ it('leaves text without lists as it was', () => {
62
+ const [content, styles] = converter.toHtml(text([{ text: 'Plain' }]), layouts);
63
+ const { container } = render(<>{content}</>);
64
+ expect(container.firstElementChild!.className).not.toContain('rt-paragraph');
65
+ expect(styles).not.toContain('cntrl-list');
66
+ });
67
+ });
@@ -4,14 +4,37 @@ import {
4
4
  getLayoutMediaQuery,
5
5
  TextTransform,
6
6
  Layout,
7
- RichTextItem,
8
7
  VerticalAlign,
9
8
  RichTextStyle,
10
- RichTextEntity
9
+ RichTextEntity,
10
+ RichTextBlock,
11
+ TextAlign
11
12
  } from '@cntrl-site/sdk';
12
13
  import { LinkWrapper } from '../../components/items/LinkWrapper';
13
14
  import { getFontFamilyValue } from '../getFontFamilyValue';
14
15
 
16
+ /** Rich text to draw: a rich text item, or a block of a section's structured content that holds text. */
17
+ export interface RichTextSource {
18
+ id: string;
19
+ commonParams: {
20
+ text: string;
21
+ blocks?: RichTextBlock[];
22
+ };
23
+ layoutParams: Record<string, {
24
+ rangeStyles?: RichTextStyle[];
25
+ textAlign: TextAlign;
26
+ }>;
27
+ }
28
+
29
+ interface ToHtmlOptions {
30
+ /**
31
+ * An item is drawn at its layout's exemplary width and scaled to the viewport as a whole, so once
32
+ * the layout is known its lengths are pixels of the exemplary. Text in the page's flow, whose box a
33
+ * scale would leave at the unscaled size, takes viewport units throughout.
34
+ */
35
+ isScaledToViewport?: boolean;
36
+ }
37
+
15
38
  interface StyleGroup {
16
39
  start: number;
17
40
  end: number;
@@ -41,12 +64,47 @@ export const RICH_TEXT_LAYOUT_PENDING_CLASS = 'rich-text-layout-pending';
41
64
 
42
65
  const SCALING_STYLES = new Set(['FONTSIZE', 'LINEHEIGHT', 'LETTERSPACING', 'WORDSPACING']);
43
66
 
67
+ const LIST_ITEM_CLASSES: Record<string, string> = {
68
+ 'unordered-list-item': 'rt-unordered-list-item',
69
+ 'ordered-list-item': 'rt-ordered-list-item'
70
+ };
71
+
72
+ // a list item's marker hangs before its first line; a numbered run restarts after anything else
73
+ const LIST_STYLES = `
74
+ .rt-unordered-list-item,
75
+ .rt-ordered-list-item {
76
+ padding-left: 1.5em;
77
+ text-indent: -1.5em;
78
+ }
79
+ .rt-unordered-list-item::before,
80
+ .rt-ordered-list-item::before {
81
+ display: inline-block;
82
+ width: 1.5em;
83
+ text-indent: 0;
84
+ }
85
+ .rt-unordered-list-item::before {
86
+ content: '•';
87
+ }
88
+ .rt-paragraph,
89
+ .rt-unordered-list-item {
90
+ counter-reset: cntrl-list;
91
+ }
92
+ .rt-ordered-list-item {
93
+ counter-increment: cntrl-list;
94
+ }
95
+ .rt-ordered-list-item::before {
96
+ content: counter(cntrl-list) '.';
97
+ }
98
+ `;
99
+
44
100
  export class RichTextConverter {
45
101
  toHtml(
46
- richText: RichTextItem,
47
- layouts: Layout[]
102
+ richText: RichTextSource,
103
+ layouts: Layout[],
104
+ { isScaledToViewport = true }: ToHtmlOptions = {}
48
105
  ): [ReactNode[], string] {
49
106
  const { text, blocks = [] } = richText.commonParams;
107
+ const hasLists = blocks.some(block => block.type in LIST_ITEM_CLASSES);
50
108
  const root: ReactElement[] = [];
51
109
  const styleRules = layouts.reduce<Record<string, string[]>>((rec, layout) => {
52
110
  rec[layout.id] = [];
@@ -62,16 +120,18 @@ export class RichTextConverter {
62
120
  const block = blocks[blockIndex];
63
121
  const content = text.slice(block.start, block.end + 1);
64
122
  const entities = block.entities!.sort((a, b) => a.start - b.start) ?? [];
123
+ const typeClass = hasLists ? ` ${LIST_ITEM_CLASSES[block.type] ?? 'rt-paragraph'}` : '';
65
124
  if (content.length === 1) {
66
125
  const id = `rt_${richText.id}_br_${blockIndex}`;
67
- root.push(<div key={id} className={id}><br /></div>);
126
+ root.push(<div key={id} className={`${id}${typeClass}`}><br /></div>);
68
127
  layouts.forEach(l => {
69
128
  const lhForLayout = currentLineHeight[l.id];
70
129
  if (lhForLayout === undefined) return;
71
130
  const lineHeightStyle = { name: 'LINEHEIGHT', value: lhForLayout };
72
- const lh = RichTextConverter.fromRangeStylesToInline(lineHeightStyle, l.exemplary, true);
73
- const lhPending = RichTextConverter.fromRangeStylesToInline(lineHeightStyle, l.exemplary, false);
131
+ const lh = RichTextConverter.fromRangeStylesToInline(lineHeightStyle, l.exemplary, isScaledToViewport);
74
132
  styleRules[l.id].push(`.rt_${richText.id}_br_${blockIndex} {${lh}}`);
133
+ if (!isScaledToViewport) return;
134
+ const lhPending = RichTextConverter.fromRangeStylesToInline(lineHeightStyle, l.exemplary, false);
75
135
  styleRules[l.id].push(`.${RICH_TEXT_LAYOUT_PENDING_CLASS} .rt_${richText.id}_br_${blockIndex} {${lhPending}}`);
76
136
  });
77
137
  continue;
@@ -158,27 +218,29 @@ export class RichTextConverter {
158
218
  for (const styleGroup of entitiesGroup.stylesGroup) {
159
219
  const lineHeight = styleGroup.styles.find(s => s.name === 'LINEHEIGHT');
160
220
  const color = styleGroup.styles.find(s => s.name === 'COLOR');
221
+ const highlight = styleGroup.styles.find(s => s.name === 'HIGHLIGHT');
161
222
  if (lineHeight?.value) {
162
223
  currentLineHeight[item.layout] = lineHeight.value;
163
224
  }
164
225
  styleRules[item.layout].push(`
165
226
  .${blockClass} .s-${styleGroup.start}-${styleGroup.end} {
166
- ${styleGroup.styles.map(s => RichTextConverter.fromRangeStylesToInline(s, exemplary, true)).join('\n')}
227
+ ${styleGroup.styles.map(s => RichTextConverter.fromRangeStylesToInline(s, exemplary, isScaledToViewport)).join('\n')}
167
228
  }
168
229
  `);
169
230
  const lengthStyles = styleGroup.styles.filter(s => SCALING_STYLES.has(s.name));
170
- if (lengthStyles.length !== 0) {
231
+ if (isScaledToViewport && lengthStyles.length !== 0) {
171
232
  styleRules[item.layout].push(`
172
233
  .${RICH_TEXT_LAYOUT_PENDING_CLASS} .${blockClass} .s-${styleGroup.start}-${styleGroup.end} {
173
234
  ${lengthStyles.map(s => RichTextConverter.fromRangeStylesToInline(s, exemplary, false)).join('\n')}
174
235
  }
175
236
  `);
176
237
  }
177
- if (color) {
238
+ if (color?.value || highlight?.value) {
178
239
  styleRules[item.layout].push(`
179
240
  @supports not (color: oklch(42% 0.3 90 / 1)) {
180
241
  .${blockClass} .s-${styleGroup.start}-${styleGroup.end} {
181
- color: ${CntrlColor.parse(getResolvedValue(color.value, 'COLOR')!).fmt('rgba')};
242
+ ${color?.value ? `color: ${CntrlColor.parse(color.value).fmt('rgba')};` : ''}
243
+ ${highlight?.value ? `background-color: ${CntrlColor.parse(highlight.value).fmt('rgba')};` : ''}
182
244
  }
183
245
  }
184
246
  `);
@@ -186,14 +248,14 @@ export class RichTextConverter {
186
248
  }
187
249
  }
188
250
  }
189
- root.push(<div key={blockClass} className={blockClass}>{kids}</div>);
251
+ root.push(<div key={blockClass} className={`${blockClass}${typeClass}`}>{kids}</div>);
190
252
  }
191
253
  }
192
254
  const styles = layouts.map(l => `
193
255
  ${getLayoutMediaQuery(l.id, layouts)} {
194
256
  ${styleRules[l.id].join('\n')}
195
257
  }
196
- `).join('\n');
258
+ `).join('\n') + (hasLists ? LIST_STYLES : '');
197
259
  return [
198
260
  root,
199
261
  styles
@@ -291,6 +353,7 @@ export class RichTextConverter {
291
353
  const { value, name } = draftStyle;
292
354
  const map: Record<string, Record<string, string | undefined>> = {
293
355
  COLOR: { color: getResolvedValue(value, name) },
356
+ HIGHLIGHT: { 'background-color': getResolvedValue(value, name) },
294
357
  TYPEFACE: { 'font-family': `${getFontFamilyValue(value!)}` },
295
358
  FONTSTYLE: value ? { ...FontStyles[value] } : {},
296
359
  FONTWEIGHT: { 'font-weight': value },
@@ -329,7 +392,7 @@ function getScaledValue(value: string | undefined, exemplary: number, isLayoutDe
329
392
  }
330
393
 
331
394
  function getResolvedValue(value: string | undefined, name: string) {
332
- if (name !== 'COLOR') return value;
395
+ if (name !== 'COLOR' && name !== 'HIGHLIGHT') return value;
333
396
  return value ? CntrlColor.parse(value).toCss() : value;
334
397
  }
335
398
 
@@ -0,0 +1,16 @@
1
+ import { VimeoEmbedItem } from '@cntrl-site/sdk';
2
+
3
+ /** The player's url with how the layout plays the video, and none of Vimeo's own title, byline or portrait. */
4
+ export function getVimeoEmbedUrl(url: string, layoutParams: VimeoEmbedItem['layoutParams'][string]): string {
5
+ const validURL = new URL(url);
6
+ validURL.searchParams.append('controls', String(layoutParams.controls));
7
+ validURL.searchParams.append('autoplay', String(layoutParams.play === 'auto'));
8
+ validURL.searchParams.append('muted', String(layoutParams.muted));
9
+ validURL.searchParams.append('loop', String(layoutParams.loop));
10
+ validURL.searchParams.append('pip', String(layoutParams.pictureInPicture));
11
+ validURL.searchParams.append('title', '0');
12
+ validURL.searchParams.append('byline', '0');
13
+ validURL.searchParams.append('portrait', '0');
14
+ validURL.searchParams.append('autopause', 'false');
15
+ return validURL.href;
16
+ }