@ciderpress/ui 1.0.0-rc.10 → 1.0.0-rc.11

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 (80) hide show
  1. package/dist/head/css/loader-apple.css +1 -1
  2. package/dist/head/css/loader-dots.css +1 -1
  3. package/dist/head/css/themes/amber.css +1 -1
  4. package/dist/head/css/themes/arcade.css +1 -1
  5. package/dist/head/css/themes/grannysmith.css +1 -1
  6. package/dist/head/css/themes/honeycrisp.css +1 -1
  7. package/dist/head/css/themes/midnight.css +1 -1
  8. package/dist/head/css/themes/mulled.css +1 -1
  9. package/dist/node.mjs +408 -15
  10. package/dist/theme/components/home/cta.css +15 -0
  11. package/dist/theme/components/home/cta.tsx +10 -2
  12. package/dist/theme/components/home/feature-card.css +1 -1
  13. package/dist/theme/components/home/feature-card.tsx +3 -2
  14. package/dist/theme/components/home/feature.tsx +34 -31
  15. package/dist/theme/components/home/hero-demo.css +11 -0
  16. package/dist/theme/components/home/home-visual.tsx +226 -0
  17. package/dist/theme/components/home/layout.tsx +350 -171
  18. package/dist/theme/components/home/split.css +46 -19
  19. package/dist/theme/components/home/split.tsx +28 -8
  20. package/dist/theme/components/home/tabs.css +299 -0
  21. package/dist/theme/components/home/tabs.tsx +351 -0
  22. package/dist/theme/components/home/trust-strip.css +41 -0
  23. package/dist/theme/components/home/trust-strip.tsx +168 -11
  24. package/dist/theme/components/home/workspaces.tsx +171 -69
  25. package/dist/theme/components/workspaces/card.css +4 -0
  26. package/dist/theme/components/workspaces/card.tsx +3 -3
  27. package/dist/theme/components/workspaces/grid.tsx +18 -6
  28. package/dist/theme/index.tsx +5 -0
  29. package/dist/theme/lib/rich-text-parse.ts +740 -0
  30. package/dist/theme/lib/rich-text.test.tsx +342 -0
  31. package/dist/theme/lib/rich-text.tsx +86 -0
  32. package/dist/theme/styles/overrides/fonts.css +7 -4
  33. package/dist/theme/styles/overrides/home.css +11 -2
  34. package/dist/theme/styles/overrides/rspress.css +10 -5
  35. package/dist/theme/styles/overrides/tokens.css +41 -23
  36. package/dist/theme/styles/rich-text.css +106 -0
  37. package/dist/theme/styles/themes/amber.css +32 -2
  38. package/dist/theme/styles/themes/arcade.css +16 -1
  39. package/dist/theme/styles/themes/grannysmith.css +32 -2
  40. package/dist/theme/styles/themes/honeycrisp.css +37 -7
  41. package/dist/theme/styles/themes/midnight.css +16 -1
  42. package/dist/theme/styles/themes/mulled.css +54 -9
  43. package/package.json +3 -3
  44. package/src/theme/components/home/cta.css +15 -0
  45. package/src/theme/components/home/cta.tsx +10 -2
  46. package/src/theme/components/home/feature-card.css +1 -1
  47. package/src/theme/components/home/feature-card.tsx +3 -2
  48. package/src/theme/components/home/feature.tsx +34 -31
  49. package/src/theme/components/home/hero-demo.css +11 -0
  50. package/src/theme/components/home/home-visual.tsx +226 -0
  51. package/src/theme/components/home/layout.tsx +350 -171
  52. package/src/theme/components/home/split.css +46 -19
  53. package/src/theme/components/home/split.tsx +28 -8
  54. package/src/theme/components/home/tabs.css +299 -0
  55. package/src/theme/components/home/tabs.tsx +351 -0
  56. package/src/theme/components/home/trust-strip.css +41 -0
  57. package/src/theme/components/home/trust-strip.tsx +168 -11
  58. package/src/theme/components/home/workspaces.tsx +171 -69
  59. package/src/theme/components/workspaces/card.css +4 -0
  60. package/src/theme/components/workspaces/card.tsx +3 -3
  61. package/src/theme/components/workspaces/grid.tsx +18 -6
  62. package/src/theme/index.tsx +5 -0
  63. package/src/theme/lib/rich-text-parse.ts +740 -0
  64. package/src/theme/lib/rich-text.test.tsx +342 -0
  65. package/src/theme/lib/rich-text.tsx +86 -0
  66. package/src/theme/styles/overrides/fonts.css +7 -4
  67. package/src/theme/styles/overrides/home.css +11 -2
  68. package/src/theme/styles/overrides/rspress.css +10 -5
  69. package/src/theme/styles/overrides/tokens.css +41 -23
  70. package/src/theme/styles/rich-text.css +106 -0
  71. package/src/theme/styles/themes/amber.css +32 -2
  72. package/src/theme/styles/themes/arcade.css +16 -1
  73. package/src/theme/styles/themes/grannysmith.css +32 -2
  74. package/src/theme/styles/themes/honeycrisp.css +37 -7
  75. package/src/theme/styles/themes/midnight.css +16 -1
  76. package/src/theme/styles/themes/mulled.css +54 -9
  77. package/dist/theme/components/home/hero-demo-custom.tsx +0 -116
  78. package/dist/theme/components/home/split-visual-custom.tsx +0 -26
  79. package/src/theme/components/home/hero-demo-custom.tsx +0 -116
  80. package/src/theme/components/home/split-visual-custom.tsx +0 -26
package/dist/node.mjs CHANGED
@@ -12,6 +12,7 @@ import path_0 from "path";
12
12
  import { Project, SyntaxKind as external_ts_morph_SyntaxKind } from "ts-morph";
13
13
  import node_process from "node:process";
14
14
  import katex from "katex";
15
+ import { unfold } from "massaman/array";
15
16
  import react from "react";
16
17
  import { fileURLToPath as __rspack_fileURLToPath } from "node:url";
17
18
  import { dirname as __rspack_dirname } from "node:path";
@@ -17358,7 +17359,7 @@ function buildLabelCss(label) {
17358
17359
  margin-top: -14px;
17359
17360
  text-align: left;
17360
17361
  color: var(--cp-c-text-2, #888);
17361
- font-family: 'Geist Pixel Square', ui-sans-serif, system-ui, sans-serif;
17362
+ font-family: var(--cp-ff-display, 'Geist Pixel Square', ui-sans-serif, system-ui, sans-serif);
17362
17363
  font-size: 28px;
17363
17364
  font-weight: 400;
17364
17365
  line-height: 1;
@@ -17451,6 +17452,408 @@ function mermaidPlugin(options = {}) {
17451
17452
  builderConfig: remarkMermaid.builderConfig
17452
17453
  };
17453
17454
  }
17455
+ const SAFE_PROTOCOLS = new Set([
17456
+ 'http:',
17457
+ 'https:',
17458
+ 'mailto:',
17459
+ 'tel:'
17460
+ ]);
17461
+ const PROTOCOL_PATTERN = /^[a-z][a-z0-9+.-]*:/i;
17462
+ const FORBIDDEN_CHARS_PATTERN = /[\u0000-\u0020\u007F]/u;
17463
+ function safeUrl(href) {
17464
+ const trimmed = href.trim();
17465
+ if (FORBIDDEN_CHARS_PATTERN.test(trimmed)) return null;
17466
+ return match_match(trimmed).with('', ()=>null).with(P.string.startsWith('#'), ()=>trimmed).with(P.string.startsWith('/'), ()=>trimmed).with(P.string.startsWith('./'), ()=>trimmed).with(P.string.startsWith('../'), ()=>trimmed).otherwise(()=>{
17467
+ const match_ = trimmed.match(PROTOCOL_PATTERN);
17468
+ if (!match_) return trimmed;
17469
+ const scheme = match_[0].toLowerCase();
17470
+ if (SAFE_PROTOCOLS.has(scheme)) return trimmed;
17471
+ return null;
17472
+ });
17473
+ }
17474
+ const ALLOWED_TAGS = new Set([
17475
+ 'b',
17476
+ 'strong',
17477
+ 'i',
17478
+ 'em',
17479
+ 'code',
17480
+ 'kbd',
17481
+ 'mark',
17482
+ 'sup',
17483
+ 'sub',
17484
+ 'span',
17485
+ 'small',
17486
+ 'u',
17487
+ 's',
17488
+ 'del',
17489
+ 'ins'
17490
+ ]);
17491
+ const STRIPPED_TAGS = new Set([
17492
+ "script",
17493
+ 'style',
17494
+ 'iframe',
17495
+ 'object',
17496
+ 'embed',
17497
+ 'template',
17498
+ "noscript"
17499
+ ]);
17500
+ const ACCENT_CLASS = 'cp-accent';
17501
+ const MARK_CLASS = 'cp-mark';
17502
+ const TOKEN_PATTERN = /(?:\\([*=`[\]<\\]))|(?:`([^`]+)`)|(?:\*\*([\s\S]+?)\*\*)|(?:==([\s\S]+?)==)|(?:(?<!\*)\*(?=[^\s*])([^*\n]+?)(?<=[^\s*])\*(?!\*))|(?:\[([^\]]*)\]\(((?:[^()\s]|\([^()\s]*\))+)\))|(?:<(\/?)\s*([a-zA-Z][a-zA-Z0-9-]*)(?=[\s/>])((?:"[^"]*"|'[^']*'|[^>"'])*)>)/;
17503
+ const ATTR_PATTERN = /([a-zA-Z-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
17504
+ const CLOSE_TAG_BOUNDARY = /[\s>]/;
17505
+ function parseRichText(text) {
17506
+ if ('string' != typeof text || 0 === text.length) return [];
17507
+ return parseInline({
17508
+ input: text,
17509
+ inLink: false
17510
+ });
17511
+ }
17512
+ function toPlainText(text) {
17513
+ return flattenNodes(parseRichText(text));
17514
+ }
17515
+ function parseInline({ input, inLink }) {
17516
+ return unfold((remaining)=>{
17517
+ if (0 === remaining.length) return false;
17518
+ const found = TOKEN_PATTERN.exec(remaining);
17519
+ if (null === found) return [
17520
+ [
17521
+ {
17522
+ kind: 'text',
17523
+ value: remaining
17524
+ }
17525
+ ],
17526
+ ''
17527
+ ];
17528
+ const lead = remaining.slice(0, found.index);
17529
+ const after = remaining.slice(found.index + found[0].length);
17530
+ const step = consumeToken({
17531
+ found,
17532
+ after,
17533
+ inLink
17534
+ });
17535
+ return [
17536
+ [
17537
+ ...textNodes(lead),
17538
+ ...step.nodes
17539
+ ],
17540
+ step.rest
17541
+ ];
17542
+ }, input).flat();
17543
+ }
17544
+ function textNodes(lead) {
17545
+ if (0 === lead.length) return [];
17546
+ return [
17547
+ {
17548
+ kind: 'text',
17549
+ value: lead
17550
+ }
17551
+ ];
17552
+ }
17553
+ function consumeToken({ found, after, inLink }) {
17554
+ const [, escaped, code, bold, highlight, italic, linkText, linkHref, closing, tagName, rawAttrs] = found;
17555
+ if (void 0 !== escaped) return {
17556
+ nodes: [
17557
+ {
17558
+ kind: 'text',
17559
+ value: escaped
17560
+ }
17561
+ ],
17562
+ rest: after
17563
+ };
17564
+ if (void 0 !== code) return {
17565
+ nodes: [
17566
+ codeNode(code)
17567
+ ],
17568
+ rest: after
17569
+ };
17570
+ if (void 0 !== bold) return {
17571
+ nodes: [
17572
+ accentNode(parseInline({
17573
+ input: bold,
17574
+ inLink
17575
+ }))
17576
+ ],
17577
+ rest: after
17578
+ };
17579
+ if (void 0 !== highlight) return {
17580
+ nodes: [
17581
+ markNode(parseInline({
17582
+ input: highlight,
17583
+ inLink
17584
+ }))
17585
+ ],
17586
+ rest: after
17587
+ };
17588
+ if (void 0 !== italic) return {
17589
+ nodes: [
17590
+ rich_text_parse_element({
17591
+ tag: 'em',
17592
+ children: parseInline({
17593
+ input: italic,
17594
+ inLink
17595
+ })
17596
+ })
17597
+ ],
17598
+ rest: after
17599
+ };
17600
+ if (void 0 !== linkHref) return {
17601
+ nodes: linkNodes({
17602
+ text: linkText ?? '',
17603
+ href: linkHref,
17604
+ inLink
17605
+ }),
17606
+ rest: after
17607
+ };
17608
+ if (void 0 !== tagName) {
17609
+ const tail = (rawAttrs ?? '').trimEnd();
17610
+ const isSelfClosing = tail.endsWith('/');
17611
+ return consumeTag({
17612
+ tagName: tagName.toLowerCase(),
17613
+ attrs: match_match(isSelfClosing).with(true, ()=>tail.slice(0, -1)).otherwise(()=>tail),
17614
+ isClosing: '/' === closing,
17615
+ isSelfClosing,
17616
+ after,
17617
+ inLink
17618
+ });
17619
+ }
17620
+ return {
17621
+ nodes: [
17622
+ {
17623
+ kind: 'text',
17624
+ value: found[0]
17625
+ }
17626
+ ],
17627
+ rest: after
17628
+ };
17629
+ }
17630
+ function consumeTag(params) {
17631
+ const { tagName, attrs, isClosing, isSelfClosing, after, inLink } = params;
17632
+ if (isClosing) return {
17633
+ nodes: [],
17634
+ rest: after
17635
+ };
17636
+ if ('br' === tagName) return {
17637
+ nodes: [
17638
+ {
17639
+ kind: 'break'
17640
+ }
17641
+ ],
17642
+ rest: after
17643
+ };
17644
+ if (isSelfClosing) return {
17645
+ nodes: selfClosingNodes({
17646
+ tagName,
17647
+ attrs
17648
+ }),
17649
+ rest: after
17650
+ };
17651
+ const close = findClosingTag({
17652
+ text: after,
17653
+ tagName
17654
+ });
17655
+ if (null === close) {
17656
+ if (STRIPPED_TAGS.has(tagName)) return {
17657
+ nodes: [],
17658
+ rest: ''
17659
+ };
17660
+ return {
17661
+ nodes: [],
17662
+ rest: after
17663
+ };
17664
+ }
17665
+ const inner = after.slice(0, close.start);
17666
+ const rest = after.slice(close.end);
17667
+ if (STRIPPED_TAGS.has(tagName)) return {
17668
+ nodes: [],
17669
+ rest
17670
+ };
17671
+ if ('a' === tagName) return {
17672
+ nodes: anchorNodes({
17673
+ attrs,
17674
+ inner,
17675
+ inLink
17676
+ }),
17677
+ rest
17678
+ };
17679
+ if (ALLOWED_TAGS.has(tagName)) return {
17680
+ nodes: [
17681
+ taggedElement({
17682
+ tag: tagName,
17683
+ attrs,
17684
+ children: parseInline({
17685
+ input: inner,
17686
+ inLink
17687
+ })
17688
+ })
17689
+ ],
17690
+ rest
17691
+ };
17692
+ return {
17693
+ nodes: parseInline({
17694
+ input: inner,
17695
+ inLink
17696
+ }),
17697
+ rest
17698
+ };
17699
+ }
17700
+ function selfClosingNodes({ tagName, attrs }) {
17701
+ if (!ALLOWED_TAGS.has(tagName)) return [];
17702
+ return [
17703
+ taggedElement({
17704
+ tag: tagName,
17705
+ attrs,
17706
+ children: []
17707
+ })
17708
+ ];
17709
+ }
17710
+ function findClosingTag({ text, tagName }) {
17711
+ return scanForClose({
17712
+ lower: text.toLowerCase(),
17713
+ tagName,
17714
+ from: 0
17715
+ });
17716
+ }
17717
+ function scanForClose({ lower, tagName, from }) {
17718
+ const needle = `</${tagName}`;
17719
+ const candidates = unfold((offset)=>{
17720
+ const at = lower.indexOf(needle, offset);
17721
+ if (-1 === at) return false;
17722
+ return [
17723
+ at,
17724
+ at + needle.length
17725
+ ];
17726
+ }, from);
17727
+ const start = candidates.find((at)=>CLOSE_TAG_BOUNDARY.test(lower.charAt(at + needle.length)));
17728
+ if (void 0 === start) return null;
17729
+ const gt = lower.indexOf('>', start);
17730
+ if (-1 === gt) return null;
17731
+ return {
17732
+ start,
17733
+ end: gt + 1
17734
+ };
17735
+ }
17736
+ function parseAttrs(attrs) {
17737
+ const entries = [
17738
+ ...attrs.matchAll(ATTR_PATTERN)
17739
+ ];
17740
+ return entries.reduce((acc, entry)=>{
17741
+ const name = entry[1].toLowerCase();
17742
+ const value = entry[2] ?? entry[3] ?? '';
17743
+ return match_match(name).with('class', ()=>({
17744
+ ...acc,
17745
+ className: value
17746
+ })).with('classname', ()=>({
17747
+ ...acc,
17748
+ className: value
17749
+ })).with('title', ()=>({
17750
+ ...acc,
17751
+ title: value
17752
+ })).with('href', ()=>({
17753
+ ...acc,
17754
+ href: value
17755
+ })).otherwise(()=>acc);
17756
+ }, {});
17757
+ }
17758
+ function taggedElement({ tag, attrs, children }) {
17759
+ const parsed = parseAttrs(attrs);
17760
+ return {
17761
+ kind: 'element',
17762
+ tag,
17763
+ className: parsed.className,
17764
+ title: parsed.title,
17765
+ children
17766
+ };
17767
+ }
17768
+ function anchorNodes({ attrs, inner, inLink }) {
17769
+ const { href } = parseAttrs(attrs);
17770
+ if (void 0 === href || inLink) return parseInline({
17771
+ input: inner,
17772
+ inLink
17773
+ });
17774
+ const safe = safeUrl(href);
17775
+ if (null === safe) return parseInline({
17776
+ input: inner,
17777
+ inLink
17778
+ });
17779
+ const children = parseInline({
17780
+ input: inner,
17781
+ inLink: true
17782
+ });
17783
+ return linkOrChildren({
17784
+ href: safe,
17785
+ children
17786
+ });
17787
+ }
17788
+ function linkNodes({ text, href, inLink }) {
17789
+ const safe = safeUrl(href);
17790
+ if (null === safe || inLink) return parseInline({
17791
+ input: text,
17792
+ inLink
17793
+ });
17794
+ return linkOrChildren({
17795
+ href: safe,
17796
+ children: parseInline({
17797
+ input: text,
17798
+ inLink: true
17799
+ })
17800
+ });
17801
+ }
17802
+ function linkOrChildren({ href, children }) {
17803
+ if (0 === children.length) return [];
17804
+ return [
17805
+ {
17806
+ kind: 'link',
17807
+ href,
17808
+ children
17809
+ }
17810
+ ];
17811
+ }
17812
+ function codeNode(value) {
17813
+ return {
17814
+ kind: 'code',
17815
+ value
17816
+ };
17817
+ }
17818
+ function accentNode(children) {
17819
+ return {
17820
+ kind: 'element',
17821
+ tag: 'strong',
17822
+ className: ACCENT_CLASS,
17823
+ children
17824
+ };
17825
+ }
17826
+ function markNode(children) {
17827
+ return {
17828
+ kind: 'element',
17829
+ tag: 'mark',
17830
+ className: MARK_CLASS,
17831
+ children
17832
+ };
17833
+ }
17834
+ function rich_text_parse_element({ tag, children }) {
17835
+ return {
17836
+ kind: 'element',
17837
+ tag,
17838
+ children
17839
+ };
17840
+ }
17841
+ function flattenNodes(nodes) {
17842
+ return nodes.map(flattenNode).join('');
17843
+ }
17844
+ function flattenNode(node) {
17845
+ return match_match(node).with({
17846
+ kind: 'text'
17847
+ }, (n)=>n.value).with({
17848
+ kind: 'code'
17849
+ }, (n)=>n.value).with({
17850
+ kind: 'break'
17851
+ }, ()=>' ').with({
17852
+ kind: 'link'
17853
+ }, (n)=>flattenNodes(n.children)).with({
17854
+ kind: 'element'
17855
+ }, (n)=>flattenNodes(n.children)).exhaustive();
17856
+ }
17454
17857
  const DEFAULT_LOADER_MIN_MS = 150;
17455
17858
  const DEFAULT_LOADER_MAX_MS = 5000;
17456
17859
  const VSCODE_SET_JS = "document.documentElement.dataset.ciderpressEnv='vscode'";
@@ -17546,8 +17949,8 @@ function createRspressConfig(options) {
17546
17949
  cleanUrls: true
17547
17950
  },
17548
17951
  llms: true,
17549
- title: config.title ?? 'ciderpress',
17550
- description: config.description ?? 'Documentation',
17952
+ title: toPlainText(config.title ?? 'ciderpress'),
17953
+ description: toPlainText(config.description ?? 'Documentation'),
17551
17954
  icon: resolveFaviconPath(favicon),
17552
17955
  logo: match_match(logoRaw).with(P.string, (l)=>l).otherwise(()=>''),
17553
17956
  logoText: '',
@@ -17860,20 +18263,10 @@ function buildSiteBlock(params) {
17860
18263
  };
17861
18264
  }
17862
18265
  function resolveHomeConfig(home) {
17863
- const fallbackShowcase = {
17864
- columns: 2
17865
- };
17866
18266
  if (void 0 === home) return {
17867
- hero: {},
17868
- showcase: fallbackShowcase
17869
- };
17870
- return {
17871
- ...home,
17872
- showcase: {
17873
- columns: 2,
17874
- ...home.showcase
17875
- }
18267
+ hero: {}
17876
18268
  };
18269
+ return home;
17877
18270
  }
17878
18271
  function buildHeadScriptBody(options) {
17879
18272
  const minimalRegistry = options.registry.map((entry)=>({
@@ -9,6 +9,21 @@
9
9
  padding: 0 32px;
10
10
  }
11
11
 
12
+ .cp-cta__eyebrow {
13
+ font-family: var(--cp-font-family-mono);
14
+ font-size: var(--cp-fs-eyebrow);
15
+ text-transform: uppercase;
16
+ letter-spacing: var(--cp-letter-eyebrow);
17
+ /* Same shade pair as `.cp-accent` — `--cp-c-brand-1` is a fill colour and
18
+ misses 4.5:1 at this size on several themes. See styles/rich-text.css. */
19
+ color: var(--cp-c-brand-2, var(--cp-c-brand-1));
20
+ margin-bottom: var(--cp-s-12);
21
+ }
22
+
23
+ html[data-cp-variant='dark'] .cp-cta__eyebrow {
24
+ color: var(--cp-c-brand-light, var(--cp-c-brand-1));
25
+ }
26
+
12
27
  .cp-cta__title {
13
28
  font-size: clamp(32px, 5vw, 56px);
14
29
  font-weight: 600;
@@ -1,6 +1,7 @@
1
- import { match } from 'massaman/match'
1
+ import { match, P } from 'massaman/match'
2
2
  import type React from 'react'
3
3
 
4
+ import { renderRichText } from '../../lib/rich-text.tsx'
4
5
  import { RouteLink } from '../../lib/route-link.tsx'
5
6
  import { safeUrl } from '../../lib/safe-url.ts'
6
7
  import type { HeroAction } from './hero'
@@ -8,6 +9,10 @@ import type { HeroAction } from './hero'
8
9
  import './cta.css'
9
10
 
10
11
  export interface CTAProps {
12
+ /**
13
+ * Small uppercase kicker rendered above the headline.
14
+ */
15
+ readonly eyebrow?: string
11
16
  /**
12
17
  * Headline.
13
18
  */
@@ -30,12 +35,15 @@ export interface CTAProps {
30
35
  * @returns React element.
31
36
  */
32
37
  export function CTA(props: CTAProps): React.ReactElement {
33
- const { title, subtitle, actions } = props
38
+ const { eyebrow, title, subtitle, actions } = props
34
39
  const list = actions ?? []
35
40
 
36
41
  return (
37
42
  <section className="cp-cta">
38
43
  <div className="cp-cta__inner">
44
+ {match(eyebrow)
45
+ .with(P.string, (e) => <div className="cp-cta__eyebrow">{renderRichText(e)}</div>)
46
+ .otherwise(() => null)}
39
47
  <h2 className="cp-cta__title">{title}</h2>
40
48
  {match(subtitle)
41
49
  .with(undefined, () => null)
@@ -18,7 +18,7 @@
18
18
  }
19
19
 
20
20
  .cp-feature-card__title {
21
- font-family: 'Geist Pixel Square', var(--rp-font-family-base, sans-serif);
21
+ font-family: var(--cp-ff-display);
22
22
  font-size: var(--cp-fs-feature-title);
23
23
  font-weight: var(--cp-fw-bold);
24
24
  color: var(--rp-c-text-1, var(--cp-c-text-1));
@@ -2,6 +2,7 @@ import { match, P } from 'massaman/match'
2
2
  import type React from 'react'
3
3
 
4
4
  import './feature-card.css'
5
+ import { renderRichText } from '../../lib/rich-text.tsx'
5
6
  import { Card } from '../shared/card'
6
7
  import { CardIcon } from '../shared/card-icon'
7
8
  import type { CardIconInput } from '../shared/resolve-card-icon'
@@ -64,14 +65,14 @@ export function FeatureCard({
64
65
  className={clampClass('cp-feature-card__title', titleLines)}
65
66
  style={clampStyle(titleLines)}
66
67
  >
67
- {title}
68
+ {renderRichText(title)}
68
69
  </span>
69
70
  </div>
70
71
  <span
71
72
  className={clampClass('cp-feature-card__desc', descriptionLines)}
72
73
  style={clampStyle(descriptionLines)}
73
74
  >
74
- {description}
75
+ {renderRichText(description)}
75
76
  </span>
76
77
  {linkTail}
77
78
  </Card>
@@ -1,16 +1,22 @@
1
- import type { HomeFeaturesConfig } from '@ciderpress/config'
2
- import { useFrontmatter } from '@rspress/core/runtime'
1
+ import type { TruncateConfig } from '@ciderpress/config'
3
2
  import { match, P } from 'massaman/match'
4
3
  import type React from 'react'
5
4
 
6
- import { useCiderpress } from '../../hooks/use-ciderpress'
5
+ import { renderRichText } from '../../lib/rich-text.tsx'
7
6
  import { FeatureCard } from './feature-card'
8
7
  import type { FeatureItem } from './feature-card'
9
8
 
10
9
  interface FrontmatterFeaturesHeading {
11
10
  readonly label?: string
12
11
  readonly title?: string
13
- readonly subtitle?: string
12
+ readonly body?: string
13
+ }
14
+
15
+ interface HomeFeatureProps {
16
+ readonly items?: readonly FeatureItem[]
17
+ readonly heading?: FrontmatterFeaturesHeading
18
+ readonly columns?: 1 | 2 | 3 | 4
19
+ readonly truncate?: TruncateConfig
14
20
  }
15
21
 
16
22
  const DEFAULT_HEADING_EYEBROW = 'Features'
@@ -19,23 +25,15 @@ const DEFAULT_HEADING_SUBTITLE =
19
25
  "Everything you need, nothing you don't. Configured in TypeScript, validated at boot."
20
26
 
21
27
  /**
22
- * Custom HomeFeature override for ciderpress.
23
- * Uses useFrontmatter() hook to read features and renders with FeatureCard/FeatureGrid styling.
28
+ * Features grid block. Renders the supplied feature cards under a heading,
29
+ * or nothing when no cards are present.
24
30
  *
25
- * @returns React element with feature grid or null
31
+ * @param props - Resolved feature cards, optional heading, and truncation limits.
32
+ * @returns React element with the feature grid, or null.
26
33
  */
27
- export function HomeFeature(): React.ReactElement | null {
28
- const { frontmatter } = useFrontmatter()
29
- const { home } = useCiderpress()
30
- const gridConfig = home && home.features
31
-
32
- // Rspress types frontmatter as its own FrontMatterMeta shape which does not
33
- // include ciderpress-specific `features`. The double cast is necessary because
34
- // no shared Zod schema exists for frontmatter validation at runtime.
35
- const fm = frontmatter as Record<string, unknown>
36
- const features = fm.features as readonly FeatureItem[] | undefined
37
- const heading = fm.featuresHeading as FrontmatterFeaturesHeading | undefined
38
- // Frontmatter is unvalidated user content — a `featuresHeading.title: {}`
34
+ export function HomeFeature(props: HomeFeatureProps): React.ReactElement | null {
35
+ const { items, heading, columns, truncate } = props
36
+ // Frontmatter is unvalidated user content — a `heading.title: {}`
39
37
  // would otherwise render as `[object Object]` in the H2. Treat any
40
38
  // non-string value as missing and fall through to the framework default.
41
39
  const headingEyebrow = match(heading && heading.label)
@@ -44,22 +42,27 @@ export function HomeFeature(): React.ReactElement | null {
44
42
  const headingTitle = match(heading && heading.title)
45
43
  .with(P.string, (s) => s)
46
44
  .otherwise(() => DEFAULT_HEADING_TITLE)
47
- const headingSubtitle = match(heading && heading.subtitle)
45
+ const headingSubtitle = match(heading && heading.body)
48
46
  .with(P.string, (s) => s)
49
47
  .otherwise(() => DEFAULT_HEADING_SUBTITLE)
48
+ // Column count rides on a custom property so the grid keeps its CSS
49
+ // media queries — a narrow viewport still collapses to one column.
50
+ const gridStyle = match(columns)
51
+ .with(P.number, (c) => ({ '--cp-feature-cols': String(c) }) as React.CSSProperties)
52
+ .otherwise(() => undefined)
50
53
 
51
- return match(features)
54
+ return match(items)
52
55
  .with(
53
56
  P.when((f): f is readonly FeatureItem[] => Array.isArray(f) && f.length > 0),
54
- (items) => (
57
+ (list) => (
55
58
  <div className="cp-feature-section">
56
59
  <div className="cp-feature-section-head">
57
- <div className="cp-feature-section-head__eyebrow">{headingEyebrow}</div>
58
- <h2 className="cp-feature-section-head__title">{headingTitle}</h2>
59
- <p className="cp-feature-section-head__sub">{headingSubtitle}</p>
60
+ <div className="cp-feature-section-head__eyebrow">{renderRichText(headingEyebrow)}</div>
61
+ <h2 className="cp-feature-section-head__title">{renderRichText(headingTitle)}</h2>
62
+ <p className="cp-feature-section-head__sub">{renderRichText(headingSubtitle)}</p>
60
63
  </div>
61
- <div className="cp-feature-grid">
62
- {items.map((f, i) => renderFeature(f, i, gridConfig))}
64
+ <div className="cp-feature-grid" style={gridStyle}>
65
+ {list.map((f, i) => renderFeature(f, i, truncate))}
63
66
  </div>
64
67
  </div>
65
68
  )
@@ -74,16 +77,16 @@ export function HomeFeature(): React.ReactElement | null {
74
77
  * @private
75
78
  * @param feature - Feature item data
76
79
  * @param index - Array index for key generation
77
- * @param gridConfig - Optional grid config for truncation
80
+ * @param truncate - Optional line-clamp limits for card text
78
81
  * @returns Feature card element
79
82
  */
80
83
  function renderFeature(
81
84
  feature: FeatureItem,
82
85
  index: number,
83
- gridConfig: HomeFeaturesConfig | undefined
86
+ truncate: TruncateConfig | undefined
84
87
  ): React.ReactElement {
85
- const titleLines = gridConfig && gridConfig.truncate && gridConfig.truncate.title
86
- const descLines = gridConfig && gridConfig.truncate && gridConfig.truncate.description
88
+ const titleLines = truncate && truncate.title
89
+ const descLines = truncate && truncate.description
87
90
 
88
91
  return (
89
92
  <FeatureCard
@@ -72,6 +72,17 @@
72
72
  padding: 0;
73
73
  }
74
74
 
75
+ /* Code-form hero demo — the frame owns the chrome, so strip the native
76
+ Rspress code block's own margin and rounded corners. */
77
+ .cp-hero-demo--code .rp-codeblock {
78
+ margin: 0;
79
+ border-radius: 0;
80
+ }
81
+
82
+ .cp-hero-demo--code .rp-codeblock pre {
83
+ overflow-x: auto;
84
+ }
85
+
75
86
  .cp-hero-demo__img {
76
87
  display: block;
77
88
  width: 100%;