@oneie/plugin-pages 0.2.0 → 0.2.1
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/package.json +25 -8
- package/src/components/FAQSection.tsx +7 -5
- package/src/components/FooterSection.tsx +16 -8
- package/src/components/HowItWorks.tsx +4 -3
- package/src/components/LandingFeatures.tsx +7 -5
- package/src/components/LandingHero.tsx +18 -13
- package/src/components/PricingSection.tsx +9 -5
- package/src/components/ProofBar.tsx +4 -3
- package/src/components/SecondaryCTA.tsx +17 -13
- package/src/components/Testimonials.tsx +7 -5
- package/src/config.tsx +170 -64
- package/src/lib/surface-chrome.ts +34 -0
- package/tests/no-borrowed-identity.test.ts +134 -0
- package/tests/plugin-pages.test.ts +99 -26
package/package.json
CHANGED
|
@@ -1,8 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oneie/plugin-pages",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Astro integration that renders a ONE workspace's visually-edited Puck pages on any Astro site.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"astro-integration",
|
|
8
|
+
"astro-component",
|
|
9
|
+
"astro",
|
|
10
|
+
"withastro",
|
|
11
|
+
"puck",
|
|
12
|
+
"cms",
|
|
13
|
+
"visual-editor",
|
|
14
|
+
"pages",
|
|
15
|
+
"one"
|
|
16
|
+
],
|
|
17
|
+
"homepage": "https://github.com/one-ie/one",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/one-ie/packages.git",
|
|
21
|
+
"directory": "plugin-pages"
|
|
22
|
+
},
|
|
6
23
|
"type": "module",
|
|
7
24
|
"main": "src/index.ts",
|
|
8
25
|
"exports": {
|
|
@@ -15,18 +32,18 @@
|
|
|
15
32
|
"build": "tsc --noEmit",
|
|
16
33
|
"test": "vitest run"
|
|
17
34
|
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"astro": ">=6.0.0",
|
|
37
|
+
"@oneie/frontend": "^0.1.2",
|
|
38
|
+
"@puckeditor/core": ">=0.21.0",
|
|
39
|
+
"react": ">=19.0.0"
|
|
40
|
+
},
|
|
18
41
|
"dependencies": {
|
|
19
42
|
"clsx": "^2.1.1",
|
|
20
43
|
"lucide-react": "^1.14.0",
|
|
21
44
|
"radix-ui": "^1.6.2",
|
|
22
45
|
"tailwind-merge": "^3.5.0"
|
|
23
46
|
},
|
|
24
|
-
"peerDependencies": {
|
|
25
|
-
"astro": ">=6.0.0",
|
|
26
|
-
"@oneie/frontend": "^0.1.1",
|
|
27
|
-
"@puckeditor/core": ">=0.21.0",
|
|
28
|
-
"react": ">=19.0.0"
|
|
29
|
-
},
|
|
30
47
|
"devDependencies": {
|
|
31
48
|
"astro": "^6.2.2",
|
|
32
49
|
"@puckeditor/core": "^0.21.3",
|
|
@@ -3,7 +3,7 @@ import { useState } from 'react'
|
|
|
3
3
|
import { ChevronDown } from 'lucide-react'
|
|
4
4
|
import { emitClick } from '../lib/ui-signal'
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
import { onText } from '../lib/surface-chrome'
|
|
7
7
|
|
|
8
8
|
interface FAQ {
|
|
9
9
|
q: string
|
|
@@ -16,19 +16,21 @@ interface FAQSectionProps {
|
|
|
16
16
|
bgToken?: string
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
// No default title — an omitted prop renders no heading, never borrowed copy.
|
|
20
|
+
export function FAQSection({ title = '', faqs, bgToken }: FAQSectionProps) {
|
|
20
21
|
const [open, setOpen] = useState<number | null>(null)
|
|
21
22
|
|
|
22
23
|
const hasBgToken = !!bgToken && bgToken !== 'none'
|
|
23
|
-
|
|
24
|
+
// Per-token contrast. Was `text-on-primary` for all three brand fills.
|
|
25
|
+
const onTextClass = onText(bgToken)
|
|
24
26
|
|
|
25
27
|
return (
|
|
26
28
|
<section
|
|
27
|
-
className={`px-6 py-24 ${hasBgToken ? '' : 'bg-foreground/30'}${
|
|
29
|
+
className={`px-6 py-24 ${hasBgToken ? '' : 'bg-foreground/30'}${onTextClass ? ` ${onTextClass}` : ''}`}
|
|
28
30
|
style={hasBgToken ? { backgroundColor: `var(--color-${bgToken})` } : undefined}
|
|
29
31
|
>
|
|
30
32
|
<div className="max-w-3xl mx-auto">
|
|
31
|
-
<h2 className="text-3xl font-bold tracking-tight text-center mb-14">{title}</h2>
|
|
33
|
+
{title && <h2 className="text-3xl font-bold tracking-tight text-center mb-14">{title}</h2>}
|
|
32
34
|
<div className="flex flex-col gap-2">
|
|
33
35
|
{faqs.map((faq, i) => (
|
|
34
36
|
<article
|
|
@@ -29,18 +29,24 @@ const ICONS = {
|
|
|
29
29
|
youtube: Share2,
|
|
30
30
|
} as const
|
|
31
31
|
|
|
32
|
+
// No brand / copyright defaults. Both named ONE on EVERY render path (these are
|
|
33
|
+
// default PARAMETERS, not Puck defaultProps — they fire whenever the prop is
|
|
34
|
+
// omitted, on the Astro lane and in the editor alike), so a site that simply did
|
|
35
|
+
// not pass them footered someone else's identity.
|
|
32
36
|
export function FooterSection({
|
|
33
|
-
brand = '
|
|
37
|
+
brand = '',
|
|
34
38
|
links = [],
|
|
35
39
|
socials = [],
|
|
36
|
-
copyright =
|
|
40
|
+
copyright = '',
|
|
37
41
|
}: FooterSectionProps) {
|
|
38
42
|
return (
|
|
39
43
|
<footer>
|
|
40
44
|
<div className="mx-auto flex max-w-7xl items-center justify-between gap-3 px-4 py-4 max-md:flex-col sm:px-6 sm:py-6 md:gap-6 md:py-8">
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
45
|
+
{brand && (
|
|
46
|
+
<a href="#" className="flex items-center gap-3 font-semibold text-lg">
|
|
47
|
+
{brand}
|
|
48
|
+
</a>
|
|
49
|
+
)}
|
|
44
50
|
|
|
45
51
|
<nav className="flex items-center gap-5 whitespace-nowrap">
|
|
46
52
|
{links.map((link, i) => (
|
|
@@ -64,9 +70,11 @@ export function FooterSection({
|
|
|
64
70
|
|
|
65
71
|
<Separator />
|
|
66
72
|
|
|
67
|
-
|
|
68
|
-
<
|
|
69
|
-
|
|
73
|
+
{copyright && (
|
|
74
|
+
<div className="mx-auto flex max-w-7xl justify-center px-4 py-8 sm:px-6">
|
|
75
|
+
<p className="text-center font-medium text-balance">{copyright}</p>
|
|
76
|
+
</div>
|
|
77
|
+
)}
|
|
70
78
|
</footer>
|
|
71
79
|
)
|
|
72
80
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Ported verbatim from one.ie/web/src/components/cro/HowItWorks.tsx
|
|
2
|
-
|
|
2
|
+
import { onText } from '../lib/surface-chrome'
|
|
3
3
|
|
|
4
4
|
interface Step {
|
|
5
5
|
label: string
|
|
@@ -16,11 +16,12 @@ interface HowItWorksProps {
|
|
|
16
16
|
|
|
17
17
|
export function HowItWorks({ title = 'How it works', subtitle, steps, bgToken }: HowItWorksProps) {
|
|
18
18
|
const hasBgToken = !!bgToken && bgToken !== 'none'
|
|
19
|
-
|
|
19
|
+
// Per-token contrast. Was `text-on-primary` for all three brand fills.
|
|
20
|
+
const onTextClass = onText(bgToken)
|
|
20
21
|
|
|
21
22
|
return (
|
|
22
23
|
<section
|
|
23
|
-
className={`px-6 py-24${
|
|
24
|
+
className={`px-6 py-24${onTextClass ? ` ${onTextClass}` : ''}`}
|
|
24
25
|
style={hasBgToken ? { backgroundColor: `var(--color-${bgToken})` } : undefined}
|
|
25
26
|
>
|
|
26
27
|
<div className="max-w-4xl mx-auto">
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import type { LucideIcon } from 'lucide-react'
|
|
3
3
|
import { IconBadge, type IconBadgeTone } from './IconBadge'
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
import { onText } from '../lib/surface-chrome'
|
|
6
6
|
|
|
7
7
|
export interface LandingFeature {
|
|
8
8
|
headline: string
|
|
@@ -20,24 +20,26 @@ interface LandingFeaturesProps {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
export function LandingFeatures({
|
|
23
|
-
title
|
|
23
|
+
// No default title — an omitted prop renders no heading, never borrowed copy.
|
|
24
|
+
title = '',
|
|
24
25
|
subtitle,
|
|
25
26
|
features,
|
|
26
27
|
columns = 3,
|
|
27
28
|
bgToken,
|
|
28
29
|
}: LandingFeaturesProps) {
|
|
29
30
|
const hasBgToken = !!bgToken && bgToken !== 'none'
|
|
30
|
-
|
|
31
|
+
// Per-token contrast. Was `text-on-primary` for all three brand fills.
|
|
32
|
+
const onTextClass = onText(bgToken)
|
|
31
33
|
|
|
32
34
|
const gridClass = columns === 2 ? 'grid sm:grid-cols-2 gap-8' : 'grid sm:grid-cols-2 lg:grid-cols-3 gap-8'
|
|
33
35
|
|
|
34
36
|
return (
|
|
35
37
|
<section
|
|
36
|
-
className={`px-6 py-24 ${hasBgToken ? '' : 'bg-foreground/30'}${
|
|
38
|
+
className={`px-6 py-24 ${hasBgToken ? '' : 'bg-foreground/30'}${onTextClass ? ` ${onTextClass}` : ''}`}
|
|
37
39
|
style={hasBgToken ? { backgroundColor: `var(--color-${bgToken})` } : undefined}
|
|
38
40
|
>
|
|
39
41
|
<div className="max-w-5xl mx-auto">
|
|
40
|
-
<h2 className="text-3xl font-bold tracking-tight text-center mb-3">{title}</h2>
|
|
42
|
+
{title && <h2 className="text-3xl font-bold tracking-tight text-center mb-3">{title}</h2>}
|
|
41
43
|
{subtitle && <p className="text-font/60 text-center mb-14 max-w-xl mx-auto">{subtitle}</p>}
|
|
42
44
|
{!subtitle && <div className="mb-14" />}
|
|
43
45
|
<div className={gridClass}>
|
|
@@ -7,7 +7,7 @@ import { Icon } from './Icon'
|
|
|
7
7
|
import { emitClick } from '../lib/ui-signal'
|
|
8
8
|
import { safeHref } from '../lib/safe-url'
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
import { onText } from '../lib/surface-chrome'
|
|
11
11
|
|
|
12
12
|
interface CTA {
|
|
13
13
|
label: string
|
|
@@ -33,16 +33,19 @@ export function LandingHero({
|
|
|
33
33
|
subhead,
|
|
34
34
|
primaryCta,
|
|
35
35
|
secondaryCta,
|
|
36
|
-
|
|
36
|
+
// No default: an omitted prop must render NOTHING. A payment/setup claim that
|
|
37
|
+
// fires whenever an author leaves this out is the defect this empty string fixes.
|
|
38
|
+
frictionText = '',
|
|
37
39
|
visual,
|
|
38
40
|
bgToken,
|
|
39
41
|
}: LandingHeroProps) {
|
|
40
42
|
const hasBgToken = !!bgToken && bgToken !== 'none'
|
|
41
|
-
|
|
43
|
+
// Per-token contrast. Was `text-on-primary` for all three brand fills.
|
|
44
|
+
const onTextClass = onText(bgToken)
|
|
42
45
|
|
|
43
46
|
return (
|
|
44
47
|
<section
|
|
45
|
-
className={`px-6 pt-24 pb-20 md:pt-32 md:pb-28${
|
|
48
|
+
className={`px-6 pt-24 pb-20 md:pt-32 md:pb-28${onTextClass ? ` ${onTextClass}` : ''}`}
|
|
46
49
|
style={hasBgToken ? { backgroundColor: `var(--color-${bgToken})` } : undefined}
|
|
47
50
|
>
|
|
48
51
|
<div className="max-w-4xl mx-auto text-center">
|
|
@@ -70,15 +73,17 @@ export function LandingHero({
|
|
|
70
73
|
</p>
|
|
71
74
|
|
|
72
75
|
<div className="flex flex-col sm:flex-row gap-3 justify-center mb-4">
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
76
|
+
{primaryCta?.label && (
|
|
77
|
+
<a
|
|
78
|
+
href={safeHref(primaryCta.href) ?? '#'}
|
|
79
|
+
onClick={() => emitClick('ui:landing:primary-cta', { page: headline })}
|
|
80
|
+
className="inline-flex items-center justify-center gap-2 px-8 py-4 bg-primary text-on-primary rounded-full font-medium text-base hover:brightness-110 transition"
|
|
81
|
+
>
|
|
82
|
+
{primaryCta.label}
|
|
83
|
+
<Icon icon={ArrowRight} size="md" />
|
|
84
|
+
</a>
|
|
85
|
+
)}
|
|
86
|
+
{secondaryCta?.label && (
|
|
82
87
|
<a
|
|
83
88
|
href={safeHref(secondaryCta.href) ?? '#'}
|
|
84
89
|
onClick={() => emitClick('ui:landing:secondary-cta', { page: headline })}
|
|
@@ -23,9 +23,11 @@ interface PricingSectionProps {
|
|
|
23
23
|
tiers: PricingTier[]
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
// No default title or subtitle. The subtitle was a DISCOUNT CLAIM that fired on
|
|
27
|
+
// every render where the prop was omitted; the title is the same class one line up.
|
|
26
28
|
export function PricingSection({
|
|
27
|
-
title = '
|
|
28
|
-
subtitle = '
|
|
29
|
+
title = '',
|
|
30
|
+
subtitle = '',
|
|
29
31
|
tiers,
|
|
30
32
|
}: PricingSectionProps) {
|
|
31
33
|
const [annual, setAnnual] = useState(true)
|
|
@@ -33,8 +35,8 @@ export function PricingSection({
|
|
|
33
35
|
return (
|
|
34
36
|
<section className="px-6 py-24 bg-foreground/30">
|
|
35
37
|
<div className="max-w-5xl mx-auto">
|
|
36
|
-
<h2 className="text-3xl font-bold tracking-tight text-center mb-3">{title}</h2>
|
|
37
|
-
<p className="text-font/60 text-center mb-10 max-w-xl mx-auto">{subtitle}</p>
|
|
38
|
+
{title && <h2 className="text-3xl font-bold tracking-tight text-center mb-3">{title}</h2>}
|
|
39
|
+
{subtitle && <p className="text-font/60 text-center mb-10 max-w-xl mx-auto">{subtitle}</p>}
|
|
38
40
|
|
|
39
41
|
<div className="flex justify-center mb-12">
|
|
40
42
|
<div
|
|
@@ -48,7 +50,9 @@ export function PricingSection({
|
|
|
48
50
|
}}
|
|
49
51
|
className={`px-4 py-1.5 rounded-full text-sm font-medium transition-colors ${annual ? 'bg-primary text-on-primary' : 'text-font/60 hover:text-font'}`}
|
|
50
52
|
>
|
|
51
|
-
|
|
53
|
+
{/* The saving was hardcoded here — unoverridable at any price. A
|
|
54
|
+
percentage claim belongs in `subtitle`, which an author controls. */}
|
|
55
|
+
Annual
|
|
52
56
|
</button>
|
|
53
57
|
<button
|
|
54
58
|
onClick={() => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Ported verbatim from one.ie/web/src/components/cro/ProofBar.tsx
|
|
2
|
-
|
|
2
|
+
import { onText } from '../lib/surface-chrome'
|
|
3
3
|
|
|
4
4
|
interface Stat {
|
|
5
5
|
label: string
|
|
@@ -22,11 +22,12 @@ interface ProofBarProps {
|
|
|
22
22
|
|
|
23
23
|
export function ProofBar({ stats, leadQuote, bgToken }: ProofBarProps) {
|
|
24
24
|
const hasBgToken = !!bgToken && bgToken !== 'none'
|
|
25
|
-
|
|
25
|
+
// Per-token contrast. Was `text-on-primary` for all three brand fills.
|
|
26
|
+
const onTextClass = onText(bgToken)
|
|
26
27
|
|
|
27
28
|
return (
|
|
28
29
|
<section
|
|
29
|
-
className={`px-6 py-12 border-y${
|
|
30
|
+
className={`px-6 py-12 border-y${onTextClass ? ` ${onTextClass}` : ''}`}
|
|
30
31
|
style={{ borderColor: 'var(--color-border)', ...(hasBgToken ? { backgroundColor: `var(--color-${bgToken})` } : {}) }}
|
|
31
32
|
>
|
|
32
33
|
<div className="max-w-5xl mx-auto">
|
|
@@ -5,7 +5,7 @@ import { Icon } from './Icon'
|
|
|
5
5
|
import { emitClick } from '../lib/ui-signal'
|
|
6
6
|
import { safeHref } from '../lib/safe-url'
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
import { onText } from '../lib/surface-chrome'
|
|
9
9
|
|
|
10
10
|
interface SecondaryCTAProps {
|
|
11
11
|
headline: string
|
|
@@ -21,15 +21,17 @@ export function SecondaryCTA({
|
|
|
21
21
|
subhead,
|
|
22
22
|
primaryCta,
|
|
23
23
|
secondaryCta,
|
|
24
|
-
|
|
24
|
+
// No default — see LandingHero. An omitted prop renders nothing, never a claim.
|
|
25
|
+
frictionText = '',
|
|
25
26
|
bgToken,
|
|
26
27
|
}: SecondaryCTAProps) {
|
|
27
28
|
const hasBgToken = !!bgToken && bgToken !== 'none'
|
|
28
|
-
|
|
29
|
+
// Per-token contrast. Was `text-on-primary` for all three brand fills.
|
|
30
|
+
const onTextClass = onText(bgToken)
|
|
29
31
|
|
|
30
32
|
return (
|
|
31
33
|
<section
|
|
32
|
-
className={`px-6 py-24${
|
|
34
|
+
className={`px-6 py-24${onTextClass ? ` ${onTextClass}` : ''}`}
|
|
33
35
|
style={hasBgToken ? { backgroundColor: `var(--color-${bgToken})` } : undefined}
|
|
34
36
|
>
|
|
35
37
|
<div className="max-w-2xl mx-auto text-center">
|
|
@@ -37,15 +39,17 @@ export function SecondaryCTA({
|
|
|
37
39
|
{subhead && <p className="text-font/60 mb-10 text-lg leading-snug">{subhead}</p>}
|
|
38
40
|
{!subhead && <div className="mb-10" />}
|
|
39
41
|
<div className="flex flex-col sm:flex-row gap-3 justify-center mb-4">
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
42
|
+
{primaryCta?.label && (
|
|
43
|
+
<a
|
|
44
|
+
href={safeHref(primaryCta.href) ?? '#'}
|
|
45
|
+
onClick={() => emitClick('ui:landing:secondary-cta', { section: 'bottom-cta' })}
|
|
46
|
+
className="inline-flex items-center justify-center gap-2 px-8 py-4 bg-primary text-on-primary rounded-full font-medium text-base hover:brightness-110 transition"
|
|
47
|
+
>
|
|
48
|
+
{primaryCta.label}
|
|
49
|
+
<Icon icon={ArrowRight} size="md" />
|
|
50
|
+
</a>
|
|
51
|
+
)}
|
|
52
|
+
{secondaryCta?.label && (
|
|
49
53
|
<a
|
|
50
54
|
href={safeHref(secondaryCta.href) ?? '#'}
|
|
51
55
|
className="inline-flex items-center justify-center gap-2 px-8 py-4 text-font rounded-full font-medium text-base hover:text-font/70 transition border"
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Ported verbatim from one.ie/web/src/components/cro/Testimonials.tsx
|
|
2
|
-
|
|
2
|
+
import { onText } from '../lib/surface-chrome'
|
|
3
3
|
|
|
4
4
|
interface Testimonial {
|
|
5
5
|
quote: string
|
|
@@ -14,17 +14,19 @@ interface TestimonialsProps {
|
|
|
14
14
|
bgToken?: string
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
// No default title — an omitted prop renders no heading, never borrowed copy.
|
|
18
|
+
export function Testimonials({ title = '', testimonials, bgToken }: TestimonialsProps) {
|
|
18
19
|
const hasBgToken = !!bgToken && bgToken !== 'none'
|
|
19
|
-
|
|
20
|
+
// Per-token contrast. Was `text-on-primary` for all three brand fills.
|
|
21
|
+
const onTextClass = onText(bgToken)
|
|
20
22
|
|
|
21
23
|
return (
|
|
22
24
|
<section
|
|
23
|
-
className={`px-6 py-24${
|
|
25
|
+
className={`px-6 py-24${onTextClass ? ` ${onTextClass}` : ''}`}
|
|
24
26
|
style={hasBgToken ? { backgroundColor: `var(--color-${bgToken})` } : undefined}
|
|
25
27
|
>
|
|
26
28
|
<div className="max-w-5xl mx-auto">
|
|
27
|
-
<h2 className="text-3xl font-bold tracking-tight text-center mb-14">{title}</h2>
|
|
29
|
+
{title && <h2 className="text-3xl font-bold tracking-tight text-center mb-14">{title}</h2>}
|
|
28
30
|
<div className="grid sm:grid-cols-3 gap-6">
|
|
29
31
|
{testimonials.map((t) => (
|
|
30
32
|
<blockquote
|
package/src/config.tsx
CHANGED
|
@@ -19,10 +19,25 @@
|
|
|
19
19
|
// (Puck drops unknown types) — same distribution-lag limitation plugin-blog/
|
|
20
20
|
// plugin-docs already carry for their own registries.
|
|
21
21
|
//
|
|
22
|
-
// `fields`
|
|
23
|
-
// Puck's read-only <Render>, which never consults
|
|
24
|
-
//
|
|
25
|
-
//
|
|
22
|
+
// `fields` WERE `{}` on all nine blocks, with the reasoning that this package only
|
|
23
|
+
// calls Puck's read-only <Render>, which never consults them. Two things were wrong
|
|
24
|
+
// with that. First, the editor DOES: a caseworker who inserts one of these blocks is
|
|
25
|
+
// shown one input per declared field and NOTHING when the map is empty, so an empty
|
|
26
|
+
// `fields` is an uneditable block. Second, the contract's load-bearing guard — every
|
|
27
|
+
// `fields` key exists in the component's props type — is vacuously true against an
|
|
28
|
+
// empty map: zero keys is zero possible mismatches, so the guard passed nine times
|
|
29
|
+
// and proved nothing. The six blocks the EHC lane keeps now declare real fields,
|
|
30
|
+
// DERIVED FROM EACH COMPONENT'S PROPS TYPE (see ./components/*.tsx). That is interim:
|
|
31
|
+
// the contract wants one declaration — zod → z.infer → props → fields — and the Zod
|
|
32
|
+
// page schema (the Astro lane's A1) does not exist yet. Re-point these at it when it
|
|
33
|
+
// lands; until then two declarations are being kept in step by hand.
|
|
34
|
+
//
|
|
35
|
+
// `defaultProps` carry NO content. They used to ship ONE.ie's headline, two invented
|
|
36
|
+
// testimonials, three invented statistics and a price list — inert under <Render>
|
|
37
|
+
// (which merges only `config.root.defaultProps`), but the editor's insert reducer
|
|
38
|
+
// does `props: {...defaultProps, id}`, so dragging a block WROTE the fabrication into
|
|
39
|
+
// the page row, after which it renders publicly as ordinary stored props. Structural
|
|
40
|
+
// defaults only: a background token and empty arrays.
|
|
26
41
|
import type { Config } from '@puckeditor/core'
|
|
27
42
|
import { LandingHero } from './components/LandingHero'
|
|
28
43
|
import { HowItWorks } from './components/HowItWorks'
|
|
@@ -34,139 +49,230 @@ import { Testimonials } from './components/Testimonials'
|
|
|
34
49
|
import { PricingSection } from './components/PricingSection'
|
|
35
50
|
import { FooterSection } from './components/FooterSection'
|
|
36
51
|
|
|
52
|
+
// The background-token vocabulary, verbatim from one.ie/web/src/lib/puck/bg-fields.ts
|
|
53
|
+
// (the `BrandColorField` comment: 'primary' | 'secondary' | 'tertiary' | 'background' |
|
|
54
|
+
// 'foreground' | 'none'). A `select` rather than that custom field: the swatch picker
|
|
55
|
+
// reads the workspace's branding through a `@/` import this package cannot carry.
|
|
56
|
+
const bgTokenField = {
|
|
57
|
+
type: 'select' as const,
|
|
58
|
+
label: 'Background colour',
|
|
59
|
+
options: [
|
|
60
|
+
{ label: 'None', value: 'none' },
|
|
61
|
+
{ label: 'Background', value: 'background' },
|
|
62
|
+
{ label: 'Foreground', value: 'foreground' },
|
|
63
|
+
{ label: 'Primary', value: 'primary' },
|
|
64
|
+
{ label: 'Secondary', value: 'secondary' },
|
|
65
|
+
{ label: 'Tertiary', value: 'tertiary' },
|
|
66
|
+
],
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// `{ label, href }` — the CTA shape both hero blocks declare.
|
|
70
|
+
const ctaField = (label: string) => ({
|
|
71
|
+
type: 'object' as const,
|
|
72
|
+
label,
|
|
73
|
+
objectFields: {
|
|
74
|
+
label: { type: 'text' as const, label: 'Button text' },
|
|
75
|
+
href: { type: 'text' as const, label: 'Link' },
|
|
76
|
+
},
|
|
77
|
+
})
|
|
78
|
+
|
|
37
79
|
export const pagesPuckConfig: Config = {
|
|
38
80
|
components: {
|
|
39
81
|
LandingHero: {
|
|
40
|
-
fields: {
|
|
82
|
+
fields: {
|
|
83
|
+
eyebrow: { type: 'text', label: 'Eyebrow' },
|
|
84
|
+
headline: { type: 'text', label: 'Headline' },
|
|
85
|
+
highlight: { type: 'text', label: 'Highlighted words' },
|
|
86
|
+
subhead: { type: 'textarea', label: 'Subhead' },
|
|
87
|
+
primaryCta: ctaField('Primary button'),
|
|
88
|
+
secondaryCta: ctaField('Secondary button'),
|
|
89
|
+
frictionText: { type: 'text', label: 'Small print under the buttons' },
|
|
90
|
+
visual: { type: 'textarea', label: 'Visual placeholder text' },
|
|
91
|
+
bgToken: bgTokenField,
|
|
92
|
+
},
|
|
41
93
|
defaultProps: {
|
|
42
94
|
bgToken: 'none',
|
|
43
|
-
headline: 'Build and sell smarter with AI agents',
|
|
44
|
-
subhead: 'ONE connects your website, your audience, and your AI — so you can launch, grow, and earn without the usual overhead.',
|
|
45
|
-
primaryCta: { label: 'Get started free', href: '#' },
|
|
46
|
-
secondaryCta: { label: 'See how it works', href: '#how' },
|
|
47
95
|
},
|
|
48
96
|
render: (props: Record<string, unknown>) => (
|
|
49
97
|
<LandingHero
|
|
50
98
|
{...(props as any)}
|
|
51
|
-
primaryCta={(props.primaryCta as any) ?? { label: '
|
|
99
|
+
primaryCta={(props.primaryCta as any) ?? { label: '', href: '#' }}
|
|
52
100
|
secondaryCta={(props.secondaryCta as any)?.label ? (props.secondaryCta as any) : undefined}
|
|
53
101
|
/>
|
|
54
102
|
),
|
|
55
103
|
},
|
|
56
104
|
|
|
105
|
+
// Not one of the six the EHC lane keeps, so no fields yet: a caseworker who
|
|
106
|
+
// inserts it still sees no inputs. It stays REGISTERED because real seeded
|
|
107
|
+
// pages use it — an unregistered type renders as nothing.
|
|
57
108
|
ProofBar: {
|
|
58
109
|
fields: {},
|
|
59
110
|
defaultProps: {
|
|
60
111
|
bgToken: 'none',
|
|
61
|
-
stats: [
|
|
62
|
-
{ value: '12,000+', label: 'Businesses powered', source: '' },
|
|
63
|
-
{ value: '99.9%', label: 'Uptime SLA', source: '' },
|
|
64
|
-
{ value: '< 60s', label: 'Time to first agent', source: '' },
|
|
65
|
-
],
|
|
112
|
+
stats: [],
|
|
66
113
|
},
|
|
67
114
|
render: (props: Record<string, unknown>) => <ProofBar {...(props as any)} stats={(props.stats as any) ?? []} />,
|
|
68
115
|
},
|
|
69
116
|
|
|
70
117
|
HowItWorks: {
|
|
71
|
-
fields: {
|
|
118
|
+
fields: {
|
|
119
|
+
title: { type: 'text', label: 'Title' },
|
|
120
|
+
subtitle: { type: 'textarea', label: 'Subtitle' },
|
|
121
|
+
steps: {
|
|
122
|
+
type: 'array',
|
|
123
|
+
label: 'Steps',
|
|
124
|
+
arrayFields: {
|
|
125
|
+
label: { type: 'text', label: 'Step name' },
|
|
126
|
+
description: { type: 'textarea', label: 'Description' },
|
|
127
|
+
detail: { type: 'text', label: 'Small detail' },
|
|
128
|
+
},
|
|
129
|
+
defaultItemProps: { label: '', description: '', detail: '' },
|
|
130
|
+
getItemSummary: (item: { label?: string }, i?: number) => item?.label || `Step ${(i ?? 0) + 1}`,
|
|
131
|
+
},
|
|
132
|
+
bgToken: bgTokenField,
|
|
133
|
+
},
|
|
72
134
|
defaultProps: {
|
|
73
135
|
bgToken: 'none',
|
|
74
|
-
|
|
75
|
-
steps: [
|
|
76
|
-
{ label: 'Connect your site', description: 'Point ONE at your domain — takes 60 seconds.', detail: 'No code required' },
|
|
77
|
-
{ label: 'Add your agents', description: 'Pick from the library or describe one in plain English.', detail: 'Works with any model' },
|
|
78
|
-
{ label: 'Go live', description: 'Publish your page. Agents handle the rest.', detail: 'Scales automatically' },
|
|
79
|
-
],
|
|
136
|
+
steps: [],
|
|
80
137
|
},
|
|
81
138
|
render: (props: Record<string, unknown>) => <HowItWorks {...(props as any)} steps={(props.steps as any) ?? []} />,
|
|
82
139
|
},
|
|
83
140
|
|
|
84
141
|
LandingFeatures: {
|
|
85
|
-
|
|
142
|
+
// `icon` (a LucideIcon) and `tone` are props no editor can type — deliberately
|
|
143
|
+
// absent rather than declared as text, which would be a key the component
|
|
144
|
+
// cannot read the value of.
|
|
145
|
+
fields: {
|
|
146
|
+
title: { type: 'text', label: 'Title' },
|
|
147
|
+
subtitle: { type: 'textarea', label: 'Subtitle' },
|
|
148
|
+
columns: {
|
|
149
|
+
type: 'radio',
|
|
150
|
+
label: 'Columns',
|
|
151
|
+
options: [
|
|
152
|
+
{ label: '2', value: 2 },
|
|
153
|
+
{ label: '3', value: 3 },
|
|
154
|
+
],
|
|
155
|
+
},
|
|
156
|
+
features: {
|
|
157
|
+
type: 'array',
|
|
158
|
+
label: 'Features',
|
|
159
|
+
arrayFields: {
|
|
160
|
+
headline: { type: 'text', label: 'Headline' },
|
|
161
|
+
body: { type: 'textarea', label: 'Body' },
|
|
162
|
+
},
|
|
163
|
+
defaultItemProps: { headline: '', body: '' },
|
|
164
|
+
getItemSummary: (item: { headline?: string }, i?: number) => item?.headline || `Feature ${(i ?? 0) + 1}`,
|
|
165
|
+
},
|
|
166
|
+
bgToken: bgTokenField,
|
|
167
|
+
},
|
|
86
168
|
defaultProps: {
|
|
87
169
|
bgToken: 'none',
|
|
88
|
-
title: 'Everything you need',
|
|
89
|
-
subtitle: 'One platform, every capability.',
|
|
90
170
|
columns: 3,
|
|
91
|
-
features: [
|
|
92
|
-
{ headline: 'AI agents built in', body: 'Deploy agents for sales, support, and content — no integrations required.' },
|
|
93
|
-
{ headline: 'Pages that convert', body: 'Drag-and-drop blocks built for every funnel stage.' },
|
|
94
|
-
{ headline: 'Payments included', body: 'Accept crypto and card in minutes, globally.' },
|
|
95
|
-
],
|
|
171
|
+
features: [],
|
|
96
172
|
},
|
|
97
173
|
render: (props: Record<string, unknown>) => <LandingFeatures {...(props as any)} features={(props.features as any) ?? []} />,
|
|
98
174
|
},
|
|
99
175
|
|
|
176
|
+
// Not one of the six (see ProofBar).
|
|
100
177
|
Testimonials: {
|
|
101
178
|
fields: {},
|
|
102
179
|
defaultProps: {
|
|
103
180
|
bgToken: 'none',
|
|
104
|
-
|
|
105
|
-
testimonials: [
|
|
106
|
-
{ quote: 'ONE cut our onboarding time in half. The agents handle everything we used to do manually.', name: 'Sarah O.', role: 'Head of Growth', company: 'Elevate Labs' },
|
|
107
|
-
{ quote: 'We launched our course site in a weekend. The drag-and-drop editor is genuinely impressive.', name: 'Marco D.', role: 'Founder', company: 'Skill Stack' },
|
|
108
|
-
],
|
|
181
|
+
testimonials: [],
|
|
109
182
|
},
|
|
110
183
|
render: (props: Record<string, unknown>) => <Testimonials {...(props as any)} testimonials={(props.testimonials as any) ?? []} />,
|
|
111
184
|
},
|
|
112
185
|
|
|
186
|
+
// Not one of the six (see ProofBar).
|
|
113
187
|
PricingSection: {
|
|
114
188
|
fields: {},
|
|
115
189
|
defaultProps: {
|
|
116
|
-
|
|
117
|
-
subtitle: 'Start free. Scale as you grow.',
|
|
118
|
-
tiers: [
|
|
119
|
-
{ name: 'Free', monthlyPrice: '$0', annualPrice: '$0', description: 'Everything you need to get started.', cta: 'Get started', ctaHref: '#' },
|
|
120
|
-
{ name: 'Pro', monthlyPrice: '$49', annualPrice: '$39', description: 'For teams that want more power and scale.', cta: 'Start free trial', ctaHref: '#' },
|
|
121
|
-
],
|
|
190
|
+
tiers: [],
|
|
122
191
|
},
|
|
123
192
|
render: (props: Record<string, unknown>) => <PricingSection {...(props as any)} tiers={(props.tiers as any) ?? []} />,
|
|
124
193
|
},
|
|
125
194
|
|
|
126
195
|
FAQSection: {
|
|
127
|
-
fields: {
|
|
196
|
+
fields: {
|
|
197
|
+
title: { type: 'text', label: 'Title' },
|
|
198
|
+
faqs: {
|
|
199
|
+
type: 'array',
|
|
200
|
+
label: 'Questions',
|
|
201
|
+
arrayFields: {
|
|
202
|
+
q: { type: 'text', label: 'Question' },
|
|
203
|
+
a: { type: 'textarea', label: 'Answer' },
|
|
204
|
+
},
|
|
205
|
+
defaultItemProps: { q: '', a: '' },
|
|
206
|
+
getItemSummary: (item: { q?: string }, i?: number) => item?.q || `Question ${(i ?? 0) + 1}`,
|
|
207
|
+
},
|
|
208
|
+
bgToken: bgTokenField,
|
|
209
|
+
},
|
|
128
210
|
defaultProps: {
|
|
129
211
|
bgToken: 'none',
|
|
130
|
-
|
|
131
|
-
faqs: [
|
|
132
|
-
{ q: 'Do I need to know how to code?', a: 'No. ONE is designed for non-technical founders and marketers. Everything is drag-and-drop or describe-in-plain-English.' },
|
|
133
|
-
{ q: 'Can I use my own domain?', a: 'Yes. Connect any domain in under a minute from your settings panel.' },
|
|
134
|
-
],
|
|
212
|
+
faqs: [],
|
|
135
213
|
},
|
|
136
214
|
render: (props: Record<string, unknown>) => <FAQSection {...(props as any)} faqs={(props.faqs as any) ?? []} />,
|
|
137
215
|
},
|
|
138
216
|
|
|
139
217
|
SecondaryCTA: {
|
|
140
|
-
fields: {
|
|
218
|
+
fields: {
|
|
219
|
+
headline: { type: 'text', label: 'Headline' },
|
|
220
|
+
subhead: { type: 'textarea', label: 'Subhead' },
|
|
221
|
+
primaryCta: ctaField('Primary button'),
|
|
222
|
+
secondaryCta: ctaField('Secondary button'),
|
|
223
|
+
frictionText: { type: 'text', label: 'Small print under the buttons' },
|
|
224
|
+
bgToken: bgTokenField,
|
|
225
|
+
},
|
|
141
226
|
defaultProps: {
|
|
142
227
|
bgToken: 'none',
|
|
143
|
-
headline: 'Ready to start?',
|
|
144
|
-
primaryCta: { label: 'Get started', href: '#' },
|
|
145
228
|
},
|
|
146
229
|
render: (props: Record<string, unknown>) => (
|
|
147
230
|
<SecondaryCTA
|
|
148
231
|
{...(props as any)}
|
|
149
|
-
primaryCta={(props.primaryCta as any) ?? { label: '
|
|
232
|
+
primaryCta={(props.primaryCta as any) ?? { label: '', href: '#' }}
|
|
150
233
|
secondaryCta={(props.secondaryCta as any)?.label ? (props.secondaryCta as any) : undefined}
|
|
151
234
|
/>
|
|
152
235
|
),
|
|
153
236
|
},
|
|
154
237
|
|
|
155
238
|
FooterSection: {
|
|
156
|
-
fields: {
|
|
239
|
+
fields: {
|
|
240
|
+
brand: { type: 'text', label: 'Brand name' },
|
|
241
|
+
links: {
|
|
242
|
+
type: 'array',
|
|
243
|
+
label: 'Links',
|
|
244
|
+
arrayFields: {
|
|
245
|
+
label: { type: 'text', label: 'Text' },
|
|
246
|
+
href: { type: 'text', label: 'Link' },
|
|
247
|
+
},
|
|
248
|
+
defaultItemProps: { label: '', href: '' },
|
|
249
|
+
getItemSummary: (item: { label?: string }, i?: number) => item?.label || `Link ${(i ?? 0) + 1}`,
|
|
250
|
+
},
|
|
251
|
+
socials: {
|
|
252
|
+
type: 'array',
|
|
253
|
+
label: 'Social links',
|
|
254
|
+
arrayFields: {
|
|
255
|
+
// The four keys ./components/FooterSection.tsx's ICONS map answers to.
|
|
256
|
+
platform: {
|
|
257
|
+
type: 'select',
|
|
258
|
+
label: 'Platform',
|
|
259
|
+
options: [
|
|
260
|
+
{ label: 'Facebook', value: 'facebook' },
|
|
261
|
+
{ label: 'Instagram', value: 'instagram' },
|
|
262
|
+
{ label: 'Twitter', value: 'twitter' },
|
|
263
|
+
{ label: 'YouTube', value: 'youtube' },
|
|
264
|
+
],
|
|
265
|
+
},
|
|
266
|
+
href: { type: 'text', label: 'Link' },
|
|
267
|
+
},
|
|
268
|
+
defaultItemProps: { platform: 'facebook', href: '' },
|
|
269
|
+
getItemSummary: (item: { platform?: string }, i?: number) => item?.platform || `Social ${(i ?? 0) + 1}`,
|
|
270
|
+
},
|
|
271
|
+
copyright: { type: 'text', label: 'Copyright line' },
|
|
272
|
+
},
|
|
157
273
|
defaultProps: {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
{ label: 'About', href: '#' },
|
|
161
|
-
{ label: 'Features', href: '#' },
|
|
162
|
-
{ label: 'Pricing', href: '#' },
|
|
163
|
-
{ label: 'Contact', href: '#' },
|
|
164
|
-
],
|
|
165
|
-
socials: [
|
|
166
|
-
{ platform: 'twitter', href: '#' },
|
|
167
|
-
{ platform: 'instagram', href: '#' },
|
|
168
|
-
],
|
|
169
|
-
copyright: '© ONE, made for better web.',
|
|
274
|
+
links: [],
|
|
275
|
+
socials: [],
|
|
170
276
|
},
|
|
171
277
|
render: (props: Record<string, unknown>) => (
|
|
172
278
|
<FooterSection
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The contrast label for a brand fill.
|
|
3
|
+
*
|
|
4
|
+
* VENDORED from `one.ie/web/src/lib/puck/surface-chrome.ts`. This package is
|
|
5
|
+
* published to npm and rendered on external sites, so it cannot use the `@/`
|
|
6
|
+
* alias — the copy is intentional. What is NOT intentional is drift, and this
|
|
7
|
+
* file existed as drift: every block here emitted `text-on-primary` for ALL
|
|
8
|
+
* THREE brand tokens, so a `secondary` or `tertiary` fill got the `primary`
|
|
9
|
+
* contrast label and the text could land unreadable on its own background.
|
|
10
|
+
*
|
|
11
|
+
* Keep the MAPPING identical to the upstream module. The class strings must stay
|
|
12
|
+
* static literals — Tailwind cannot see `text-on-${token}` and compiles it to
|
|
13
|
+
* nothing, which is how text ends up the same colour as the fill it sits on.
|
|
14
|
+
*/
|
|
15
|
+
export type BrandToken = 'primary' | 'secondary' | 'tertiary'
|
|
16
|
+
|
|
17
|
+
export const ON_TEXT: Record<BrandToken, string> = {
|
|
18
|
+
primary: 'text-on-primary',
|
|
19
|
+
secondary: 'text-on-secondary',
|
|
20
|
+
tertiary: 'text-on-tertiary',
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const BRAND = new Set<string>(['primary', 'secondary', 'tertiary'])
|
|
24
|
+
|
|
25
|
+
export const isBrand = (t?: string | null): t is BrandToken => !!t && BRAND.has(t)
|
|
26
|
+
|
|
27
|
+
/** A token that paints something. `'none'`/absent means "leave the background alone". */
|
|
28
|
+
export const isFilled = (t?: string | null): boolean => !!t && t !== 'none'
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The contrast label for a fill. Empty string for a non-brand fill (background /
|
|
32
|
+
* foreground already sit under `text-font`) so it concatenates safely.
|
|
33
|
+
*/
|
|
34
|
+
export const onText = (t?: string | null): string => (isBrand(t) ? ON_TEXT[t] : '')
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// This package renders a local authority's public SEND site — pages read by the
|
|
2
|
+
// parent of a disabled child. A fabricated proof point, a payment claim or somebody
|
|
3
|
+
// else's brand name on one of those pages is the worst thing this estate can
|
|
4
|
+
// publish, and every instance this test guards was a DEFAULT: it fired when an
|
|
5
|
+
// author did the obvious thing and omitted a prop that did not apply.
|
|
6
|
+
//
|
|
7
|
+
// Two halves, because the two defect shapes reach the page by different doors and
|
|
8
|
+
// neither half can see the other's:
|
|
9
|
+
//
|
|
10
|
+
// A. DEFAULT PARAMETERS (`frictionText = 'No credit card · 2-minute setup'`) fire
|
|
11
|
+
// on EVERY render path — Astro lane, Puck lane, SSR, editor — whenever the prop
|
|
12
|
+
// is undefined. The block renders confidently with the wrong words, so no
|
|
13
|
+
// presence check and no empty-state test can see them. Rendering each component
|
|
14
|
+
// with only its required props is what sees them.
|
|
15
|
+
//
|
|
16
|
+
// B. `defaultProps` in config.tsx are inert under Puck's read-only <Render>, which
|
|
17
|
+
// merges only `config.root.defaultProps` (@puckeditor/core 0.21.3,
|
|
18
|
+
// dist/index.js:14185). They are NOT inert in the editor: the insert reducer
|
|
19
|
+
// does `props: {…config.components[type].defaultProps, id}` (:1933), so dragging
|
|
20
|
+
// the block WRITES the fabrication into the D1 row, after which it is ordinary
|
|
21
|
+
// stored props and renders publicly with no merge needed. Half A renders
|
|
22
|
+
// components and cannot see a single byte of that. So half B walks the config.
|
|
23
|
+
// `defaultItemProps` under `fields` is the same door one level down — it lands
|
|
24
|
+
// in the row the moment a caseworker clicks "add item" — so the walk covers the
|
|
25
|
+
// whole component config, not just `defaultProps`.
|
|
26
|
+
//
|
|
27
|
+
// RED PROOF (run 2026-09-11, both halves): restoring `frictionText = 'No credit card
|
|
28
|
+
// · 2-minute setup'` in LandingHero.tsx failed half A naming a payment claim;
|
|
29
|
+
// restoring the `12,000+ Businesses powered` stat in config.tsx's ProofBar
|
|
30
|
+
// defaultProps failed half B naming a grouped-number statistic. A checker never
|
|
31
|
+
// driven red proves nothing.
|
|
32
|
+
import { describe, it, expect } from 'vitest'
|
|
33
|
+
import { createElement, type ComponentType } from 'react'
|
|
34
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
35
|
+
import { pagesPuckConfig } from '../src/config'
|
|
36
|
+
import { LandingHero } from '../src/components/LandingHero'
|
|
37
|
+
import { HowItWorks } from '../src/components/HowItWorks'
|
|
38
|
+
import { LandingFeatures } from '../src/components/LandingFeatures'
|
|
39
|
+
import { ProofBar } from '../src/components/ProofBar'
|
|
40
|
+
import { SecondaryCTA } from '../src/components/SecondaryCTA'
|
|
41
|
+
import { FAQSection } from '../src/components/FAQSection'
|
|
42
|
+
import { Testimonials } from '../src/components/Testimonials'
|
|
43
|
+
import { PricingSection } from '../src/components/PricingSection'
|
|
44
|
+
import { FooterSection } from '../src/components/FooterSection'
|
|
45
|
+
|
|
46
|
+
// Matched against SUBSTRINGS and shapes, never against a whole sentence. The brief
|
|
47
|
+
// that commissioned this test quoted `'No credit card - 2-minute setup'` and
|
|
48
|
+
// `'(c) ONE, made for better web.'`; the files carried `'No credit card · 2-minute
|
|
49
|
+
// setup'` (U+00B7) and `'© ONE, made for better web.'`. A full-sentence assertion
|
|
50
|
+
// would have been vacuously green while the real string shipped.
|
|
51
|
+
const FORBIDDEN: ReadonlyArray<readonly [string, RegExp]> = [
|
|
52
|
+
["ONE's name", /\bONE\b/],
|
|
53
|
+
['a one.ie URL', /one\.ie/i],
|
|
54
|
+
["ONE's tagline", /made for better web/i],
|
|
55
|
+
['a payment claim', /credit card|free trial|billing/i],
|
|
56
|
+
['a price', /[$€£]\s?\d/],
|
|
57
|
+
['a percentage claim', /\d+(\.\d+)?\s?%/],
|
|
58
|
+
['a grouped-number statistic', /\b\d{1,3}(,\d{3})+/],
|
|
59
|
+
['an uptime / SLA claim', /uptime|\bSLA\b/i],
|
|
60
|
+
['a named testimonial', /Elevate Labs|Skill Stack|Sarah O\.|Marco D\./i],
|
|
61
|
+
['a setup-time claim', /\b\d+\s?-?\s?(second|minute|hour|day)s?\b/i],
|
|
62
|
+
['a time-to-value claim', /[<>]\s?\d+\s?s\b/i],
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
/** The visible words, not the markup — a class name is not something a parent reads. */
|
|
66
|
+
const visibleText = (html: string): string => html.replace(/<[^>]*>/g, ' ')
|
|
67
|
+
|
|
68
|
+
function sweep(where: string, subject: string): string[] {
|
|
69
|
+
return FORBIDDEN.filter(([, re]) => re.test(subject)).map(
|
|
70
|
+
([what, re]) => `${where} carries ${what}: ${JSON.stringify(subject.match(re)?.[0] ?? '')}`
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Only what the component's props type makes REQUIRED. Everything else is omitted on
|
|
75
|
+
// purpose: omitting a prop that does not apply is the obvious thing an EHC author
|
|
76
|
+
// does, and it is exactly what used to ship someone else's copy. Required strings are
|
|
77
|
+
// empty so that any word in the output can only have come from a default.
|
|
78
|
+
const REQUIRED_ONLY: ReadonlyArray<readonly [string, ComponentType<any>, Record<string, unknown>]> = [
|
|
79
|
+
['LandingHero', LandingHero, { headline: '', subhead: '', primaryCta: { label: '', href: '#' } }],
|
|
80
|
+
['HowItWorks', HowItWorks, { steps: [] }],
|
|
81
|
+
['LandingFeatures', LandingFeatures, { features: [] }],
|
|
82
|
+
['ProofBar', ProofBar, {}],
|
|
83
|
+
['SecondaryCTA', SecondaryCTA, { headline: '', primaryCta: { label: '', href: '#' } }],
|
|
84
|
+
['FAQSection', FAQSection, { faqs: [] }],
|
|
85
|
+
['Testimonials', Testimonials, { testimonials: [] }],
|
|
86
|
+
['PricingSection', PricingSection, { tiers: [] }],
|
|
87
|
+
['FooterSection', FooterSection, {}],
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
describe('A — a component rendered with only its required props borrows no identity', () => {
|
|
91
|
+
it('covers every block the config registers (an empty sweep must not read as a pass)', () => {
|
|
92
|
+
const registered = Object.keys(pagesPuckConfig.components).sort()
|
|
93
|
+
const covered = REQUIRED_ONLY.map(([name]) => name).sort()
|
|
94
|
+
expect(covered).toEqual(registered)
|
|
95
|
+
expect(registered.length).toBeGreaterThan(0)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it.each(REQUIRED_ONLY)('%s', (name, Component, props) => {
|
|
99
|
+
const html = renderToStaticMarkup(createElement(Component, props as any))
|
|
100
|
+
// The component must actually have rendered — a component that threw or returned
|
|
101
|
+
// null would sweep clean and prove nothing.
|
|
102
|
+
expect(html.length, `${name} rendered nothing`).toBeGreaterThan(0)
|
|
103
|
+
expect(sweep(`<${name}> with required props only`, visibleText(html))).toEqual([])
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
describe('B — the config carries no content the editor could write into a page row', () => {
|
|
108
|
+
// Every string leaf under `components`, keyed by its path. `render` is a function
|
|
109
|
+
// and is skipped; everything else — defaultProps, defaultItemProps, field labels,
|
|
110
|
+
// select option labels and values — is data that can reach a row or a caseworker's
|
|
111
|
+
// screen.
|
|
112
|
+
const strings: Array<[string, string]> = []
|
|
113
|
+
const walk = (value: unknown, path: string): void => {
|
|
114
|
+
if (typeof value === 'string') return void strings.push([path, value])
|
|
115
|
+
if (typeof value === 'function') return
|
|
116
|
+
if (Array.isArray(value)) return value.forEach((v, i) => walk(v, `${path}[${i}]`))
|
|
117
|
+
if (value && typeof value === 'object') {
|
|
118
|
+
for (const [k, v] of Object.entries(value)) walk(v, `${path}.${k}`)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
walk(pagesPuckConfig.components, 'components')
|
|
122
|
+
|
|
123
|
+
it('the walk actually visited the config (an empty walk must not read as a pass)', () => {
|
|
124
|
+
expect(strings.length).toBeGreaterThan(20)
|
|
125
|
+
// The path that matters most is named explicitly: this is the one the insert
|
|
126
|
+
// reducer copies into the row.
|
|
127
|
+
expect(strings.some(([p]) => p.includes('.defaultProps.'))).toBe(true)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('no string leaf names ONE, a price, a statistic or a person', () => {
|
|
131
|
+
const findings = strings.flatMap(([path, value]) => sweep(path, value))
|
|
132
|
+
expect(findings).toEqual([])
|
|
133
|
+
})
|
|
134
|
+
})
|
|
@@ -5,23 +5,96 @@ import { PageRenderer, type PuckData } from '../src/PageRenderer'
|
|
|
5
5
|
import { fetchPage } from '../src/fetchPage'
|
|
6
6
|
|
|
7
7
|
// Real production fixture — the exact `pages:view` response for workspace `tony`,
|
|
8
|
-
// page `beer`, captured live 2026-07-11.
|
|
9
|
-
// C1's stub-block bug (block type names that never
|
|
10
|
-
// self-
|
|
11
|
-
//
|
|
12
|
-
//
|
|
8
|
+
// page `beer`, captured live 2026-07-11. The BLOCK TYPES and ids are that capture,
|
|
9
|
+
// untouched: they are what caught C1's stub-block bug (block type names that never
|
|
10
|
+
// matched any real page), and a self-invented type list would never have caught it.
|
|
11
|
+
//
|
|
12
|
+
// The PROPS are added, and the reason is the point of the whole fixture. The capture
|
|
13
|
+
// carried `{id}` only, so what rendered was each component's own default parameter —
|
|
14
|
+
// which is what the assertions below used to read. Those defaults were ONE.ie's copy
|
|
15
|
+
// ('What you get', 'What agencies say', 'No credit card · 2-minute setup') on a
|
|
16
|
+
// package that renders a local authority's SEND site, so they are gone. Asserting on
|
|
17
|
+
// props the fixture actually passes is the stronger test anyway: it proves the key
|
|
18
|
+
// resolves to the right component AND that the component reads the prop, where the
|
|
19
|
+
// old form only proved the first. `Heading` (id only) is a structural block outside
|
|
20
|
+
// this cycle's ported set — expected to render nothing, not a failure.
|
|
13
21
|
const beerPage = {
|
|
14
22
|
root: {},
|
|
15
23
|
content: [
|
|
16
|
-
{
|
|
24
|
+
{
|
|
25
|
+
type: 'LandingHero',
|
|
26
|
+
props: {
|
|
27
|
+
id: 'LandingHero-kr5o1y',
|
|
28
|
+
headline: 'Fixture hero headline',
|
|
29
|
+
subhead: 'Fixture hero subhead',
|
|
30
|
+
primaryCta: { label: 'Fixture hero button', href: '/fixture' },
|
|
31
|
+
},
|
|
32
|
+
},
|
|
17
33
|
{ type: 'Heading', props: { id: 'Heading-jxrapx' } },
|
|
18
|
-
{
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
34
|
+
{
|
|
35
|
+
type: 'LandingFeatures',
|
|
36
|
+
props: {
|
|
37
|
+
id: 'LandingFeatures-al6pnl',
|
|
38
|
+
title: 'Fixture features title',
|
|
39
|
+
features: [{ headline: 'Fixture feature headline', body: 'Fixture feature body' }],
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
type: 'HowItWorks',
|
|
44
|
+
props: {
|
|
45
|
+
id: 'HowItWorks-6gplnc',
|
|
46
|
+
title: 'Fixture steps title',
|
|
47
|
+
steps: [{ label: 'Fixture step label', description: 'Fixture step description' }],
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
type: 'ProofBar',
|
|
52
|
+
props: { id: 'ProofBar-g3yvbh', stats: [{ value: 'Fixture value', label: 'Fixture stat label' }] },
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
type: 'SecondaryCTA',
|
|
56
|
+
props: {
|
|
57
|
+
id: 'SecondaryCTA-drayza',
|
|
58
|
+
headline: 'Fixture secondary headline',
|
|
59
|
+
primaryCta: { label: 'Fixture secondary button', href: '/fixture' },
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
type: 'FAQSection',
|
|
64
|
+
props: {
|
|
65
|
+
id: 'FAQSection-y7z0lt',
|
|
66
|
+
title: 'Fixture faq title',
|
|
67
|
+
faqs: [{ q: 'Fixture question', a: 'Fixture answer' }],
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
type: 'Testimonials',
|
|
72
|
+
props: {
|
|
73
|
+
id: 'Testimonials-9cq0fo',
|
|
74
|
+
title: 'Fixture quotes title',
|
|
75
|
+
testimonials: [
|
|
76
|
+
{ quote: 'Fixture quote', name: 'Fixture name', role: 'Fixture role', company: 'Fixture company' },
|
|
77
|
+
],
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
type: 'PricingSection',
|
|
82
|
+
props: {
|
|
83
|
+
id: 'PricingSection-mtqhe8',
|
|
84
|
+
title: 'Fixture pricing title',
|
|
85
|
+
tiers: [
|
|
86
|
+
{
|
|
87
|
+
name: 'Fixture tier',
|
|
88
|
+
monthlyPrice: 'Fixture monthly',
|
|
89
|
+
annualPrice: 'Fixture annual',
|
|
90
|
+
description: 'Fixture tier description',
|
|
91
|
+
features: ['Fixture tier feature'],
|
|
92
|
+
cta: 'Fixture tier button',
|
|
93
|
+
ctaHref: '/fixture',
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
},
|
|
97
|
+
},
|
|
25
98
|
],
|
|
26
99
|
zones: {},
|
|
27
100
|
} as unknown as PuckData
|
|
@@ -36,19 +109,19 @@ const mockFetch = (body: unknown): typeof fetch =>
|
|
|
36
109
|
describe('PageRenderer — real page content', () => {
|
|
37
110
|
it('renders every block type from a real published page (not a self-invented fixture)', () => {
|
|
38
111
|
const html = renderToStaticMarkup(createElement(PageRenderer, { data: beerPage }))
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
expect(html).toContain('
|
|
47
|
-
expect(html).toContain('
|
|
48
|
-
expect(html).toContain('
|
|
49
|
-
expect(html).toContain('
|
|
50
|
-
expect(html).toContain('
|
|
51
|
-
expect(html).toContain('
|
|
112
|
+
// Puck's read-only <Render> does NOT merge a component's config-level
|
|
113
|
+
// `defaultProps` (that is the editor's insert reducer, index.js:1933) — it passes
|
|
114
|
+
// stored props straight through. So one assertion per block type, each reading a
|
|
115
|
+
// prop only THAT component knows how to render: a stub-key config, or a key bound
|
|
116
|
+
// to the wrong component, renders none of them.
|
|
117
|
+
expect(html).toContain('Fixture hero headline') // LandingHero
|
|
118
|
+
expect(html).toContain('Fixture feature headline') // LandingFeatures (the card, not just the heading)
|
|
119
|
+
expect(html).toContain('Fixture step description') // HowItWorks
|
|
120
|
+
expect(html).toContain('Fixture stat label') // ProofBar
|
|
121
|
+
expect(html).toContain('Fixture secondary headline') // SecondaryCTA
|
|
122
|
+
expect(html).toContain('Fixture question') // FAQSection
|
|
123
|
+
expect(html).toContain('Fixture quote') // Testimonials
|
|
124
|
+
expect(html).toContain('Fixture tier') // PricingSection
|
|
52
125
|
expect(html).toContain('lucide-arrow-right') // LandingHero/SecondaryCTA CTA icon actually rendered
|
|
53
126
|
expect((html.match(/<section/g) ?? []).length).toBe(8) // 8 of the 9 fixture blocks are ported (Heading is out of scope)
|
|
54
127
|
})
|