@reblu/site-blocks 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # @reblu/site-blocks
2
2
 
3
+ ## 0.10.0
4
+
5
+ ### Minor Changes
6
+
7
+ - e78229a: REB-853 — rename the `Specialty` organism to the niche-agnostic `ParentPage` (a page with a linked grid of child pages), with props `childPages`, `childPageHref` and `childPagesLabel`.
8
+
9
+ `Specialty` / `SpecialtyProps` and the props `treatments`, `treatmentHref` and `treatmentsLabel` remain as `@deprecated` aliases — `Specialty` is the same reference as `ParentPage`, so rendering is unchanged. They will be removed in the next major, not before 2027-03-31.
10
+
11
+ ## 0.9.0
12
+
13
+ ### Minor Changes
14
+
15
+ - 14814b0: Add a shared renderer for page registry blocks and FAQ structured data (REB-850).
16
+ - `PageBlocks` (root entry, Server Component): renders `PageDetail.blocks`,
17
+ applying each block's mirrored section `heading`, `backgroundColor` and `cta`.
18
+ Every block type's body is overridable through a `components` map keyed by
19
+ canonical type. Unknown block types and embeds with `resolved: null` render
20
+ nothing; the deprecated `doctor_embed`/`specialty_embed` aliases render
21
+ through the `profile_embed`/`page_embed` renderers. Blocks render in `order`;
22
+ a `cta` slot with no heading, no usable CTA and no override renders nothing.
23
+ Pass `idPrefix` when a page renders more than one `PageBlocks` instance, so
24
+ section heading ids stay unique.
25
+ - `FaqAccordion` (root entry, Server Component): native `<details>`/`<summary>`
26
+ FAQ list with configurable heading levels; no JavaScript required.
27
+ - `buildFaqPageJsonLd` and `collectFaqItems` on the new directive-free
28
+ `@reblu/site-blocks/seo` subpath (also on the root): build a schema.org
29
+ `FAQPage` object, callable from Server Components. Questions follow block
30
+ `order` and keep the author's plain text (whitespace collapsed only), so the
31
+ structured data matches what `FaqAccordion` shows.
32
+
33
+ Additive only — no existing export changes.
34
+
3
35
  ## 0.8.0
4
36
 
5
37
  ### Minor Changes
package/README.md CHANGED
@@ -15,9 +15,11 @@ a different-looking site for every tenant, with zero forks.
15
15
  | `readingTime`, `formatPrice`, `buildShareUrls`, `markdownToSafeHtml` | `.` | Pure, framework-agnostic helpers (RSC-safe). |
16
16
  | `sanitizeHtml` | `.` | Sanitises an HTML string before `dangerouslySetInnerHTML` — the **HTML boundary**. See "Security boundaries" below. |
17
17
  | `MarkdownRenderer` | `.` | Canonical Blog renderer: `marked` → **hast-util-sanitize** (XSS) → component map, with a YouTube-domain allowlist. RSC-safe. |
18
- | `Treatment`, `Specialty`, `ProfileCard`, `ProductCard` | `.` | Presentational organisms over the SITE API contracts. RSC-safe. |
18
+ | `Treatment`, `ParentPage`, `ProfileCard`, `ProductCard` | `.` | Presentational organisms over the SITE API contracts. RSC-safe. `Specialty`: deprecated alias, see below. |
19
19
  | `ShopFilters` | `.` | Plug-and-play shop filter chrome over `GET /api/site/shop/facets`. RSC-safe. See "Shop filters" below. |
20
20
  | `CartLineList`, `CartSummary`, `AddedToCartToast`, `CheckoutLoginPrompt`, `CheckoutForm`, `OrderConfirmation` | `.` | Cart & checkout organisms. RSC-safe by construction. See "Cart & checkout" below. |
21
+ | `PageBlocks`, `FaqAccordion` | `.` | Registry-block renderer (`PageDetail.blocks`) and native `<details>` FAQ. Server Components. See "Page blocks" below. |
22
+ | `buildFaqPageJsonLd`, `collectFaqItems` | `./seo` | schema.org `FAQPage` structured data. Directive-free (also on `.`). See "Page blocks" below. |
21
23
  | `useCollection` | `./client` | Headless filterable/sortable/paginated collection hook (facets, category, tag, search). Serves both page and blog indexes. |
22
24
  | `Collection` | `./client` | Token-driven presentational grid over `useCollection`. |
23
25
  | `ShareButtons` | `./client` | Social share (X / LinkedIn / WhatsApp / copy). |
@@ -378,6 +380,150 @@ Every title/description is overridable via `labels` (see
378
380
  "`useShopOrderStatus` — the order-confirmation flow" for the full wiring
379
381
  recipe.
380
382
 
383
+ ## `ParentPage` (formerly `Specialty`)
384
+
385
+ `ParentPage` renders a page that groups other pages: the hero and content
386
+ sections of a `PageDetail`, then an optional linked grid of its child pages
387
+ (`PageListItem[]` — cover image, title, excerpt).
388
+
389
+ ```tsx
390
+ import { ParentPage } from '@reblu/site-blocks'
391
+
392
+ export function ServicesHub({ page, services }) {
393
+ return (
394
+ <ParentPage
395
+ page={page}
396
+ childPages={services}
397
+ childPageHref={(slug) => `/services/${slug}`}
398
+ childPagesLabel='Services'
399
+ />
400
+ )
401
+ }
402
+ ```
403
+
404
+ **Deprecated (REB-853) — removed in the next major, not before 2027-03-31:**
405
+
406
+ | Deprecated | Use instead |
407
+ | ----------------- | ----------------- |
408
+ | `Specialty` | `ParentPage` |
409
+ | `SpecialtyProps` | `ParentPageProps` |
410
+ | `treatments` | `childPages` |
411
+ | `treatmentHref` | `childPageHref` |
412
+ | `treatmentsLabel` | `childPagesLabel` |
413
+
414
+ `Specialty` is the same component reference as `ParentPage`, not a copy, so
415
+ existing code renders exactly as before. When a new prop and its deprecated
416
+ counterpart are both passed, the new one wins. The default grid heading stays
417
+ `"Treatments"` and the root keeps `data-slot="specialty"` until that major, so
418
+ the rename changes no markup — pass `childPagesLabel` to set your own heading.
419
+
420
+ ## Page blocks
421
+
422
+ `PageBlocks` renders a page's registry blocks (`PageDetail.blocks` from
423
+ `GET /api/site/pages/[slug]`) so a SITE does not hand-write its own block
424
+ switch. It is a **Server Component** — no hooks, no `'use client'` — exported
425
+ from the RSC-safe root entry.
426
+
427
+ ```tsx
428
+ import { PageBlocks } from '@reblu/site-blocks'
429
+ import { buildFaqPageJsonLd, collectFaqItems } from '@reblu/site-blocks/seo'
430
+ import { JsonLd } from '@reblu/site-next/json-ld'
431
+
432
+ export default async function Page({ params }) {
433
+ const page = await getPage((await params).slug)
434
+ const faq = buildFaqPageJsonLd(collectFaqItems(page.blocks))
435
+ return (
436
+ <main>
437
+ <h1>{page.title}</h1>
438
+ <PageBlocks blocks={page.blocks} embedHref={(r) => `/${r.slug}`} />
439
+ {faq && <JsonLd id='faq' data={faq} />}
440
+ </main>
441
+ )
442
+ }
443
+ ```
444
+
445
+ Each block is wrapped in a `<section data-slot="page-block" data-block-type="…">`
446
+ that applies the parent section's mirrored fields (REB-849):
447
+
448
+ - **`heading`** — rendered at `headingLevel` (default `2`) and used as the
449
+ section's accessible name. Headings inside the block go one level deeper.
450
+ - **`backgroundColor`** — applied as the section background. Theme tokens
451
+ (`var(--primary)`, `var(--secondary)`, `var(--accent)`) get their paired
452
+ `-foreground` token; a custom hex gets white or near-black text by WCAG
453
+ luminance; any other value keeps the theme foreground.
454
+ - **`cta`** — rendered after the body as a link styled with `buttonVariants`;
455
+ external CTAs open in a new tab with `rel="noopener noreferrer"`.
456
+
457
+ | Prop | Default | Purpose |
458
+ | ------------------- | -------------------- | ---------------------------------------------------------- |
459
+ | `blocks` | — | `PageDetail.blocks`. `null`/empty renders nothing. |
460
+ | `components` | `{}` | Per-type body renderer overrides (see below). |
461
+ | `headingLevel` | `2` | Level of each section heading. |
462
+ | `embedHref` | `` r => `/${r.slug}` `` | Href of an embedded page — route it per `r.pageType`. |
463
+ | `idPrefix` | `'page-block'` | Namespace for heading ids; pass distinct values when one document renders `PageBlocks` twice with the same blocks. |
464
+ | `className`, `sectionClassName` | — | Extra classes on the list / on each section. |
465
+
466
+ ### Fail-closed rules
467
+
468
+ - An **unknown `blockType`** (e.g. a type added to the API before this package
469
+ is upgraded) renders nothing — not even its heading — and never throws.
470
+ - An **embed** (`page_embed`, `profile_embed`) whose `resolved` is `null` or
471
+ absent renders nothing: no heading, no dead link.
472
+ - A **`cta` block** references a live CTA by `data.slot`, which only the SITE
473
+ can resolve; by default it renders just the section heading and mirrored
474
+ CTA, and nothing at all when it has neither. Supply `components.cta` to
475
+ render the slot.
476
+ - **Deprecated aliases** `doctor_embed` and `specialty_embed` (REB-852) render
477
+ through the `profile_embed` and `page_embed` renderers respectively.
478
+
479
+ ### Overriding a block type
480
+
481
+ `components` is a partial map keyed by **canonical** block type. An override
482
+ replaces only the block body; the section wrapper (heading, background, CTA)
483
+ still applies.
484
+
485
+ ```tsx
486
+ import { PageBlocks, type BlockRendererProps } from '@reblu/site-blocks'
487
+
488
+ function TeamCard({ block, headingLevel, embedHref }: BlockRendererProps<'profile_embed'>) {
489
+ // `block.resolved` is never null here; `block.blockType` may be the deprecated alias.
490
+ return <a href={embedHref(block.resolved)}>{block.resolved.title}</a>
491
+ }
492
+
493
+ <PageBlocks blocks={page.blocks} components={{ profile_embed: TeamCard }} />
494
+ ```
495
+
496
+ Every renderer receives `{ block, headingLevel, embedHref }`. Overrides may be
497
+ async Server Components, or Client Components when they need interactivity.
498
+
499
+ ### `FaqAccordion`
500
+
501
+ Native `<details>`/`<summary>` — opens and closes with no JavaScript, keyboard
502
+ and screen-reader semantics come from the browser. Answers render as text,
503
+ never as markup; line breaks are kept.
504
+
505
+ | Prop | Default | Purpose |
506
+ | ---------------------- | ------- | ------------------------------------------------------------- |
507
+ | `items` | — | `{ q, a }[]` — the `faq` block's `data.items`. |
508
+ | `heading` | — | Optional heading above the list. |
509
+ | `headingLevel` | `2` | Level of `heading`. |
510
+ | `questionHeadingLevel` | — | When set, wraps each question in `<h{n}>` inside its summary. |
511
+ | `defaultOpen` | `[]` | Indexes of items open on first render. |
512
+
513
+ ### `buildFaqPageJsonLd(items)`
514
+
515
+ Returns a schema.org `FAQPage` object (or `null` when no item has both a
516
+ question and an answer — an empty `FAQPage` is invalid). Questions and answers
517
+ are plain text and pass through verbatim — only whitespace is collapsed — so the
518
+ structured data says exactly what `FaqAccordion` shows (`a < b` survives).
519
+ `collectFaqItems` orders blocks by `order` with the same helper `PageBlocks`
520
+ uses, so question order matches the page. It is a pure function exported from the directive-free
521
+ `@reblu/site-blocks/seo` entry, so Server Components and `generateMetadata` can
522
+ call it — a helper reachable only through a `'use client'` entry cannot be
523
+ called on the server (REB-846). `test/seo-entry.test.ts` guards this on the
524
+ emitted `dist`. The caller serialises; `@reblu/site-next`'s `<JsonLd>` escapes
525
+ the `</script>` boundary.
526
+
381
527
  ## Security boundaries
382
528
 
383
529
  Two sinks, two different helpers, in two different packages — do not reach for