@oneie/plugin-pages 0.1.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 CHANGED
@@ -1,8 +1,25 @@
1
1
  {
2
2
  "name": "@oneie/plugin-pages",
3
- "version": "0.1.0",
4
- "description": "ONE pages renders a workspace's published Puck pages on any Astro site via the public pages:view receiver",
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": {
@@ -11,16 +28,30 @@
11
28
  "./fetchPage.ts": "./src/fetchPage.ts",
12
29
  "./PageRoute.astro": "./src/PageRoute.astro"
13
30
  },
31
+ "scripts": {
32
+ "build": "tsc --noEmit",
33
+ "test": "vitest run"
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
+ },
14
41
  "dependencies": {
15
42
  "clsx": "^2.1.1",
16
43
  "lucide-react": "^1.14.0",
17
44
  "radix-ui": "^1.6.2",
18
45
  "tailwind-merge": "^3.5.0"
19
46
  },
20
- "peerDependencies": {
21
- "astro": ">=6.0.0",
22
- "@oneie/frontend": ">=0.1.0",
23
- "@puckeditor/core": ">=0.21.0",
24
- "react": ">=19.0.0"
47
+ "devDependencies": {
48
+ "astro": "^6.2.2",
49
+ "@puckeditor/core": "^0.21.3",
50
+ "react": "^19.0.0",
51
+ "react-dom": "^19.0.0",
52
+ "@types/react": "^19.2.14",
53
+ "@types/react-dom": "^19.2.3",
54
+ "typescript": "^5.7.3",
55
+ "vitest": "^4.1.6"
25
56
  }
26
57
  }
@@ -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
- const BRAND_TOKENS = new Set(['primary', 'secondary', 'tertiary'])
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
- export function FAQSection({ title = 'Common questions', faqs, bgToken }: FAQSectionProps) {
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
- const isBrandToken = hasBgToken && BRAND_TOKENS.has(bgToken)
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'}${isBrandToken ? ' text-on-primary' : ''}`}
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 = 'ONE',
37
+ brand = '',
34
38
  links = [],
35
39
  socials = [],
36
- copyright = `©${new Date().getFullYear()} ONE, Made for better web.`,
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
- <a href="#" className="flex items-center gap-3 font-semibold text-lg">
42
- {brand}
43
- </a>
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
- <div className="mx-auto flex max-w-7xl justify-center px-4 py-8 sm:px-6">
68
- <p className="text-center font-medium text-balance">{copyright}</p>
69
- </div>
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
- const BRAND_TOKENS = new Set(['primary', 'secondary', 'tertiary'])
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
- const isBrandToken = hasBgToken && BRAND_TOKENS.has(bgToken)
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${isBrandToken ? ' text-on-primary' : ''}`}
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
- const BRAND_TOKENS = new Set(['primary', 'secondary', 'tertiary'])
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 = 'What you get',
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
- const isBrandToken = hasBgToken && BRAND_TOKENS.has(bgToken)
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'}${isBrandToken ? ' text-on-primary' : ''}`}
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
- const BRAND_TOKENS = new Set(['primary', 'secondary', 'tertiary'])
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
- frictionText = 'No credit card · 2-minute setup',
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
- const isBrandToken = hasBgToken && BRAND_TOKENS.has(bgToken)
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${isBrandToken ? ' text-on-primary' : ''}`}
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
- <a
74
- href={safeHref(primaryCta.href) ?? '#'}
75
- onClick={() => emitClick('ui:landing:primary-cta', { page: headline })}
76
- 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"
77
- >
78
- {primaryCta.label}
79
- <Icon icon={ArrowRight} size="md" />
80
- </a>
81
- {secondaryCta && (
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 = 'Simple pricing',
28
- subtitle = 'Annual billing pre-selected saves you 20%.',
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
- Annual <span className="text-xs opacity-70 ml-1">save 20%</span>
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
- const BRAND_TOKENS = new Set(['primary', 'secondary', 'tertiary'])
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
- const isBrandToken = hasBgToken && BRAND_TOKENS.has(bgToken)
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${isBrandToken ? ' text-on-primary' : ''}`}
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
- const BRAND_TOKENS = new Set(['primary', 'secondary', 'tertiary'])
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
- frictionText = 'No credit card · 2-minute setup',
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
- const isBrandToken = hasBgToken && BRAND_TOKENS.has(bgToken)
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${isBrandToken ? ' text-on-primary' : ''}`}
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
- <a
41
- href={safeHref(primaryCta.href) ?? '#'}
42
- onClick={() => emitClick('ui:landing:secondary-cta', { section: 'bottom-cta' })}
43
- 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"
44
- >
45
- {primaryCta.label}
46
- <Icon icon={ArrowRight} size="md" />
47
- </a>
48
- {secondaryCta && (
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
- const BRAND_TOKENS = new Set(['primary', 'secondary', 'tertiary'])
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
- export function Testimonials({ title = 'What agencies say', testimonials, bgToken }: TestimonialsProps) {
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
- const isBrandToken = hasBgToken && BRAND_TOKENS.has(bgToken)
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${isBrandToken ? ' text-on-primary' : ''}`}
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` below are intentionally minimal (`{}`) this package only ever calls
23
- // Puck's read-only <Render>, which never consults `fields` (editor-only metadata).
24
- // Field/defaultProps shapes are otherwise ported verbatim from the real registry
25
- // for documentation parity.
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: 'Get started', href: '#' }}
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
- title: 'How it works',
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
- fields: {},
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
- title: 'What our customers say',
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
- title: 'Simple, transparent pricing',
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
- title: 'Frequently asked questions',
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: 'Get started', href: '#' }}
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
- brand: 'ONE',
159
- links: [
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
+ })
@@ -0,0 +1,167 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { createElement } from 'react'
3
+ import { renderToStaticMarkup } from 'react-dom/server'
4
+ import { PageRenderer, type PuckData } from '../src/PageRenderer'
5
+ import { fetchPage } from '../src/fetchPage'
6
+
7
+ // Real production fixture — the exact `pages:view` response for workspace `tony`,
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.
21
+ const beerPage = {
22
+ root: {},
23
+ content: [
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
+ },
33
+ { type: 'Heading', props: { id: 'Heading-jxrapx' } },
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
+ },
98
+ ],
99
+ zones: {},
100
+ } as unknown as PuckData
101
+
102
+ const mockFetch = (body: unknown): typeof fetch =>
103
+ (async () =>
104
+ new Response(JSON.stringify(body), {
105
+ status: 200,
106
+ headers: { 'content-type': 'application/json' },
107
+ })) as unknown as typeof fetch
108
+
109
+ describe('PageRenderer — real page content', () => {
110
+ it('renders every block type from a real published page (not a self-invented fixture)', () => {
111
+ const html = renderToStaticMarkup(createElement(PageRenderer, { data: beerPage }))
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
125
+ expect(html).toContain('lucide-arrow-right') // LandingHero/SecondaryCTA CTA icon actually rendered
126
+ expect((html.match(/<section/g) ?? []).length).toBe(8) // 8 of the 9 fixture blocks are ported (Heading is out of scope)
127
+ })
128
+
129
+ it('renders nothing for a block type outside the ported set, without throwing', () => {
130
+ const onlyUnknown = { root: {}, content: [{ type: 'Heading', props: { id: 'x' } }], zones: {} } as unknown as PuckData
131
+ expect(() => renderToStaticMarkup(createElement(PageRenderer, { data: onlyUnknown }))).not.toThrow()
132
+ })
133
+
134
+ it('never renders a javascript: URL stored in a link href (stored-XSS guard)', () => {
135
+ // Page content is workspace-owner-authored but rendered to the PUBLIC with no
136
+ // login — a malicious/compromised href must not become an executable link on
137
+ // every visitor's browser, on every site that installs this package.
138
+ const maliciousPage = {
139
+ root: {},
140
+ content: [
141
+ { type: 'LandingHero', props: { id: 'h1', headline: 'x', subhead: 'y', primaryCta: { label: 'Go', href: "javascript:alert(document.cookie)" } } },
142
+ { type: 'SecondaryCTA', props: { id: 's1', headline: 'x', primaryCta: { label: 'Go', href: "javascript:alert(1)" } } },
143
+ { type: 'PricingSection', props: { id: 'p1', tiers: [{ name: 'Free', monthlyPrice: '$0', annualPrice: '$0', description: '', features: [], cta: 'Buy', ctaHref: "javascript:alert(1)" }] } },
144
+ { type: 'FooterSection', props: { id: 'f1', links: [{ label: 'Evil', href: "javascript:alert(1)" }], socials: [{ platform: 'twitter', href: "data:text/html,evil" }] } },
145
+ ],
146
+ zones: {},
147
+ } as unknown as PuckData
148
+ const html = renderToStaticMarkup(createElement(PageRenderer, { data: maliciousPage }))
149
+ expect(html).not.toContain('javascript:')
150
+ expect(html).not.toContain('data:text/html')
151
+ })
152
+ })
153
+
154
+ describe('fetchPage', () => {
155
+ it('returns the Puck data for a published page', async () => {
156
+ const envelope = { outcome: 'result', result: { ok: true, slug: 'tony', title: 'Beer', data: beerPage, url: '/p/beer' } }
157
+ const data = await fetchPage('tony', 'beer', { fetchImpl: mockFetch(envelope) })
158
+ expect(data).not.toBeNull()
159
+ expect((data as PuckData).content[0].type).toBe('LandingHero')
160
+ })
161
+
162
+ it('never returns a draft page (server filters via status=published)', async () => {
163
+ const envelope = { outcome: 'result', result: { ok: false, error: 'not_found' } }
164
+ const data = await fetchPage('tony', 'secret-draft', { fetchImpl: mockFetch(envelope) })
165
+ expect(data).toBeNull()
166
+ })
167
+ })
package/tsconfig.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "../tsconfig.base.json",
3
+ "include": ["src"]
4
+ }
@@ -0,0 +1,5 @@
1
+ import { defineConfig } from 'vitest/config'
2
+
3
+ export default defineConfig({
4
+ esbuild: { jsx: 'automatic', jsxImportSource: 'react' },
5
+ })
package/LICENSE DELETED
@@ -1,86 +0,0 @@
1
- # ONE License (Version 1.0)
2
-
3
- Copyright (c) 2024-2026 one.ie
4
-
5
- ## Maximum Freedom, One Obligation
6
-
7
- This license empowers you with complete commercial freedom to use, reuse, modify, sell and resell the software, AI and data, at any price you wish.
8
-
9
- No usage limits. No royalty fees. Just pure, unrestricted ability to innovate and profit from the software.
10
-
11
- You are free to run ONE locally, on your own servers, in the cloud and at the edge.
12
-
13
- ## You Receive
14
-
15
- ### Unlimited Rights
16
-
17
- You have unrestricted rights to use, modify, license, sublicense, distribute, sell, resell and monetize the Software without restrictions, subject only to the Brand Requirement below.
18
-
19
- ### Permitted Actions
20
-
21
- Including, but not limited to:
22
-
23
- - Commercial use and integration into any systems
24
- - Creation and sale of derivative works
25
- - Providing software as a service
26
- - AI training and content generation
27
- - Patenting innovations based on the Software
28
- - Open source applications and integrations
29
-
30
- ### License Compatibility
31
-
32
- This license is compatible with all major open-source licenses, including:
33
-
34
- - MIT License
35
- - Apache License
36
- - GNU General Public License (GPL)
37
- - BSD Licenses
38
- - Mozilla Public License
39
-
40
- ### Perpetual Rights
41
-
42
- These rights are granted in perpetuity and are irrevocable, provided the Brand Requirement is maintained.
43
-
44
- ## Intellectual Property
45
-
46
- - We retain ownership of the original Software and data.
47
- - You own any modifications you make.
48
- - You never have to share your code or data.
49
-
50
- ## Brand Requirement
51
-
52
- The only obligation is:
53
-
54
- - Don't remove the ONE brand, logo, and link to https://one.ie/ from the deployed product.
55
-
56
- To remove the Brand Requirement — white-label, no visible ONE branding — obtain the
57
- **ONE Enterprise License** (see `LICENSE-ENTERPRISE.md` or contact agent@one.ie).
58
-
59
- ## Liability and Warranty
60
-
61
- The Software and data is provided "AS IS". We bear no liability for its use.
62
-
63
- ## Termination
64
-
65
- This license terminates if you remove or hide the ONE brand, logo, or link without
66
- holding a current ONE Enterprise License.
67
-
68
- ## Governing Law and Disputes
69
-
70
- This license is governed by the laws of Ireland. The parties will attempt to resolve disputes through good-faith negotiation. If necessary, disputes will proceed to mediation under the Mediators' Institute of Ireland rules, and then to binding arbitration under the Arbitration Act 2010, seated in Dublin, conducted in English.
71
-
72
- ---
73
-
74
- This license is designed to maximize freedom to innovate and profit. There is no copyleft requirement to share any code, making it suitable for enterprise use.
75
-
76
- ## Enterprise Solutions
77
-
78
- Building something big? We're here to help:
79
-
80
- - **Free** — use every feature with the ONE brand link in the footer
81
- - **White-label** — remove the brand requirement (ONE Enterprise License)
82
- - **Custom** — white-label solutions tailored to your needs
83
- - **Enterprise** — full support and deployment assistance
84
- - **Training** — help getting your team started
85
-
86
- Contact agent@one.ie to share your needs · Learn at https://one.ie/learn · Agents: https://one.ie/llms.txt