@anthusai/papyrus 1.0.0-next.18 → 1.0.0-next.19

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.
@@ -0,0 +1,135 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { getEditionSectionPath } from "../lib/edition-routes";
5
+ import { SITE_BRAND } from "../lib/site-brand";
6
+
7
+ export type PresentationHeaderSection = {
8
+ key: string;
9
+ label: string;
10
+ };
11
+
12
+ export type PresentationHeaderProps = {
13
+ editionDate?: string;
14
+ title?: string;
15
+ description?: string;
16
+ sections?: PresentationHeaderSection[];
17
+ editionBasePath?: string;
18
+ };
19
+
20
+ export function PresentationHeader({
21
+ editionDate,
22
+ title: contentTitle,
23
+ description,
24
+ sections,
25
+ editionBasePath,
26
+ }: PresentationHeaderProps) {
27
+ const title = SITE_BRAND.mastheadSource === "brand" ? SITE_BRAND.mastheadTitle : contentTitle;
28
+ const subtitle = SITE_BRAND.mastheadSource === "brand" ? SITE_BRAND.mastheadSubtitle : description;
29
+ const eyebrow = SITE_BRAND.mastheadEyebrow ?? null;
30
+ const tagline = SITE_BRAND.mastheadTagline ?? null;
31
+ const taglineLines = SITE_BRAND.mastheadTaglineLines ?? null;
32
+ const displayDate = editionDate
33
+ ? SITE_BRAND.mastheadDateFormat === "formatted"
34
+ ? formatMastheadDate(editionDate)
35
+ : editionDate
36
+ : null;
37
+
38
+ const eyebrowParts = eyebrow ? splitEyebrow(eyebrow) : null;
39
+
40
+ return (
41
+ <>
42
+ <header className="presentation-header">
43
+ <div className="presentation-header__copy-stack">
44
+ {eyebrowParts ? (
45
+ <span className="presentation-header__eyebrow">
46
+ <span className="presentation-header__eyebrow-strong">{eyebrowParts.lead}</span>
47
+ {eyebrowParts.rest ? (
48
+ <span className="presentation-header__eyebrow-muted">{eyebrowParts.rest}</span>
49
+ ) : null}
50
+ </span>
51
+ ) : null}
52
+ <h1>
53
+ {SITE_BRAND.mastheadWordSplit && title
54
+ ? title.split(/\s+/).map((word) => (
55
+ <span className="presentation-header__word" key={word}>
56
+ <span className="presentation-header__word-text">{word}</span>
57
+ </span>
58
+ ))
59
+ : title}
60
+ </h1>
61
+ <div className="presentation-header__meta">
62
+ {subtitle && !eyebrow ? <span className="presentation-header__subtitle">{subtitle}</span> : null}
63
+ {displayDate ? <p className="presentation-header__date">{displayDate}</p> : null}
64
+ </div>
65
+ </div>
66
+ {tagline ? (
67
+ <span className="presentation-header__tagline">
68
+ {taglineLines && taglineLines.length > 0
69
+ ? taglineLines.map((line, index) => (
70
+ <span className="presentation-header__tagline-line" key={index}>
71
+ <span className="presentation-header__tagline-emphasis">{line.emphasis}</span>
72
+ <span className="presentation-header__tagline-tail">{line.tail}</span>
73
+ {index < taglineLines.length - 1 ? " " : null}
74
+ </span>
75
+ ))
76
+ : tagline}
77
+ </span>
78
+ ) : null}
79
+ </header>
80
+ {sections && sections.length > 0 ? (
81
+ <nav className="presentation-section-nav" aria-label="Edition sections">
82
+ {sections.map((section) => (
83
+ <Link
84
+ href={getSectionHref(section, { editionBasePath, editionDate })}
85
+ key={section.key}
86
+ >
87
+ {section.label}
88
+ </Link>
89
+ ))}
90
+ </nav>
91
+ ) : null}
92
+ </>
93
+ );
94
+ }
95
+
96
+ function getSectionHref(
97
+ section: PresentationHeaderSection,
98
+ options: { editionBasePath?: string; editionDate?: string },
99
+ ): string {
100
+ if (SITE_BRAND.sectionLinkStrategy === "anchor") {
101
+ const anchor = `#${getSectionAnchorId(section.key)}`;
102
+ return options.editionBasePath ? `${options.editionBasePath}${anchor}` : anchor;
103
+ }
104
+ if (options.editionBasePath) {
105
+ return `${options.editionBasePath}/section/${encodeURIComponent(section.key)}`;
106
+ }
107
+ if (options.editionDate) {
108
+ const datedPath = getEditionSectionPath(options.editionDate, section.key);
109
+ return datedPath.startsWith("//") ? `#${getSectionAnchorId(section.key)}` : datedPath;
110
+ }
111
+ return `#${getSectionAnchorId(section.key)}`;
112
+ }
113
+
114
+ export function getSectionAnchorId(sectionKey: string): string {
115
+ return `section-${sectionKey}`;
116
+ }
117
+
118
+ function splitEyebrow(text: string): { lead: string; rest: string } {
119
+ const trimmed = text.trim();
120
+ const firstSpace = trimmed.indexOf(" ");
121
+ if (firstSpace === -1) return { lead: trimmed, rest: "" };
122
+ return { lead: trimmed.slice(0, firstSpace), rest: trimmed.slice(firstSpace + 1) };
123
+ }
124
+
125
+ export function formatMastheadDate(value: string): string {
126
+ const normalized = value?.trim();
127
+ if (!normalized) return value;
128
+ const date = new Date(`${normalized}T00:00:00`);
129
+ if (Number.isNaN(date.getTime())) return value;
130
+ return new Intl.DateTimeFormat("en-US", {
131
+ month: "long",
132
+ day: "numeric",
133
+ year: "numeric",
134
+ }).format(date);
135
+ }
@@ -0,0 +1,17 @@
1
+ type PresentationRhythmHRuleProps = {
2
+ /** Hide in wide 2-column secondary pairs (items 1+2 side-by-side). */
3
+ hideInSecondaryPair?: boolean;
4
+ };
5
+
6
+ export function PresentationRhythmHRule({ hideInSecondaryPair = false }: PresentationRhythmHRuleProps) {
7
+ return (
8
+ <div
9
+ aria-hidden="true"
10
+ className="presentation-rhythm-hrule"
11
+ data-hide-in-secondary-pair={hideInSecondaryPair ? "true" : undefined}
12
+ role="presentation"
13
+ >
14
+ <div className="presentation-rhythm-hrule__line" />
15
+ </div>
16
+ );
17
+ }
@@ -0,0 +1,81 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState, type RefObject } from "react";
4
+ import type { BlogHeaderObstacle } from "./blog-page-background";
5
+
6
+ const HEADER_OBSTACLE_SELECTORS = [
7
+ ".presentation-header__eyebrow",
8
+ ".presentation-header__word",
9
+ ".presentation-header__meta",
10
+ ".presentation-header__subtitle",
11
+ ".presentation-header__date",
12
+ ".presentation-header__tagline",
13
+ ".presentation-section-nav a",
14
+ ];
15
+
16
+ function measureObstacleRect(element: HTMLElement): DOMRect {
17
+ const range = document.createRange();
18
+ range.selectNodeContents(element);
19
+ const contentRect = range.getBoundingClientRect();
20
+ range.detach();
21
+ if (contentRect.width > 0 && contentRect.height > 0) return contentRect;
22
+ return element.getBoundingClientRect();
23
+ }
24
+
25
+ /**
26
+ * Measures the rendered header text (eyebrow, masthead words, meta, tagline,
27
+ * section links) as rectangles in the coordinates of `.blog-page-background`,
28
+ * so a brand's background artwork can keep clear of them. Block-level header
29
+ * elements span the full page width even when their text ends mid-line, so
30
+ * the text range is measured instead of the element box.
31
+ */
32
+ export function useBlogHeaderObstacles(
33
+ pageRef: RefObject<HTMLElement | null>,
34
+ paintBuffer: number,
35
+ ): BlogHeaderObstacle[] {
36
+ const [obstacles, setObstacles] = useState<BlogHeaderObstacle[]>([]);
37
+
38
+ useEffect(() => {
39
+ const page = pageRef.current;
40
+ if (!page) return;
41
+
42
+ const update = () => {
43
+ const background = page.querySelector<HTMLElement>(".blog-page-background");
44
+ const backgroundRect = background?.getBoundingClientRect();
45
+ if (!backgroundRect) return;
46
+
47
+ const next: BlogHeaderObstacle[] = [];
48
+ for (const selector of HEADER_OBSTACLE_SELECTORS) {
49
+ for (const element of Array.from(page.querySelectorAll<HTMLElement>(selector))) {
50
+ const rect = measureObstacleRect(element);
51
+ const intersectionTop = Math.max(rect.top, backgroundRect.top);
52
+ const intersectionBottom = Math.min(rect.bottom, backgroundRect.bottom);
53
+ const intersectionLeft = Math.max(rect.left, backgroundRect.left);
54
+ const intersectionRight = Math.min(rect.right, backgroundRect.right);
55
+ const width = intersectionRight - intersectionLeft;
56
+ const height = intersectionBottom - intersectionTop;
57
+ if (width <= 0 || height <= 0) continue;
58
+ next.push({
59
+ x: intersectionLeft - backgroundRect.left,
60
+ y: intersectionTop - backgroundRect.top,
61
+ width,
62
+ height: height + paintBuffer * 2,
63
+ });
64
+ }
65
+ }
66
+ setObstacles(next);
67
+ };
68
+
69
+ update();
70
+ const observer = new ResizeObserver(update);
71
+ observer.observe(page);
72
+ window.addEventListener("resize", update);
73
+ void document.fonts?.ready.then(update);
74
+ return () => {
75
+ observer.disconnect();
76
+ window.removeEventListener("resize", update);
77
+ };
78
+ }, [pageRef, paintBuffer]);
79
+
80
+ return obstacles;
81
+ }
@@ -0,0 +1,31 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { listBrandFontFamilies } from "../lib/brand-fonts";
5
+ import { SITE_BRAND } from "../lib/site-brand";
6
+
7
+ /**
8
+ * Returns a number that increments once the brand's declared font families
9
+ * have loaded, so Pretext measurements taken with fallback metrics are
10
+ * re-run with the real glyph widths. Stays 0 for brands without a font slot.
11
+ */
12
+ export function useBrandFontsReadyVersion(): number {
13
+ const [version, setVersion] = useState(0);
14
+
15
+ useEffect(() => {
16
+ const families = listBrandFontFamilies(SITE_BRAND.fonts);
17
+ if (families.length === 0 || !document.fonts) return;
18
+ let isCancelled = false;
19
+ void Promise.all(families.map((family) => document.fonts.load(`16px ${JSON.stringify(family)}`)))
20
+ .then(() => document.fonts.ready)
21
+ .then(() => {
22
+ if (!isCancelled) setVersion((current) => current + 1);
23
+ })
24
+ .catch(() => undefined);
25
+ return () => {
26
+ isCancelled = true;
27
+ };
28
+ }, []);
29
+
30
+ return version;
31
+ }
@@ -0,0 +1,29 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+
5
+ export function useRhythmOverlay(): boolean {
6
+ const [showRhythmOverlay, setShowRhythmOverlay] = useState(false);
7
+
8
+ useEffect(() => {
9
+ const handleKeyDown = (event: KeyboardEvent) => {
10
+ if (isEditableEventTarget(event.target)) return;
11
+ if ((event.key === "=" || event.code === "Equal") && event.ctrlKey && !event.metaKey && !event.altKey) {
12
+ event.preventDefault();
13
+ setShowRhythmOverlay((current) => !current);
14
+ }
15
+ };
16
+
17
+ window.addEventListener("keydown", handleKeyDown);
18
+ return () => window.removeEventListener("keydown", handleKeyDown);
19
+ }, []);
20
+
21
+ return showRhythmOverlay;
22
+ }
23
+
24
+ function isEditableEventTarget(target: EventTarget | null): boolean {
25
+ if (!(target instanceof HTMLElement)) return false;
26
+ const tagName = target.tagName.toLowerCase();
27
+ if (tagName === "input" || tagName === "textarea" || tagName === "select") return true;
28
+ return target.isContentEditable || target.closest("[contenteditable='true']") !== null;
29
+ }
@@ -0,0 +1,112 @@
1
+ export type SiteBrandFontFace = {
2
+ /** Public URL of the font file, e.g. `/fonts/acme-serif-regular.woff2`. */
3
+ src: string;
4
+ weight?: number | string;
5
+ style?: "normal" | "italic";
6
+ };
7
+
8
+ export type SiteBrandGoogleFontSource = {
9
+ weights: number[];
10
+ /** Also load the italic style of every listed weight. */
11
+ italics?: boolean;
12
+ };
13
+
14
+ /**
15
+ * One brand-declared font family. Declare exactly one source: `google` loads
16
+ * the family from the Google Fonts stylesheet API; `faces` self-hosts files
17
+ * with `@font-face`. The root layout exposes `cssVariable` as
18
+ * `"<family>", <fallback>` on `<html>`, so publication CSS writes
19
+ * `font-family: var(--font-masthead)`.
20
+ */
21
+ export type SiteBrandFont = {
22
+ family: string;
23
+ cssVariable: string;
24
+ fallback?: string;
25
+ google?: SiteBrandGoogleFontSource;
26
+ faces?: SiteBrandFontFace[];
27
+ };
28
+
29
+ export type BrandFontAssets = {
30
+ preconnectOrigins: string[];
31
+ stylesheetHrefs: string[];
32
+ fontFaceCss: string;
33
+ rootStyle: Record<string, string>;
34
+ isEmpty: boolean;
35
+ };
36
+
37
+ const GOOGLE_STYLESHEET_ORIGIN = "https://fonts.googleapis.com";
38
+ const GOOGLE_FILE_ORIGIN = "https://fonts.gstatic.com";
39
+ const DEFAULT_FALLBACK = "sans-serif";
40
+ const CSS_VARIABLE_PATTERN = /^--[a-z][a-z0-9-]*$/;
41
+
42
+ export function validateBrandFonts(fonts: SiteBrandFont[]): void {
43
+ const seenVariables = new Set<string>();
44
+ for (const font of fonts) {
45
+ if (!font.family.trim()) throw new Error("A brand font needs a family name.");
46
+ if (!CSS_VARIABLE_PATTERN.test(font.cssVariable)) {
47
+ throw new Error(`Brand font "${font.family}" cssVariable must look like --font-name, got "${font.cssVariable}".`);
48
+ }
49
+ if (seenVariables.has(font.cssVariable)) {
50
+ throw new Error(`Brand font cssVariable ${font.cssVariable} is declared twice.`);
51
+ }
52
+ seenVariables.add(font.cssVariable);
53
+ const sourceCount = Number(Boolean(font.google)) + Number(Boolean(font.faces?.length));
54
+ if (sourceCount !== 1) {
55
+ throw new Error(`Brand font "${font.family}" must declare exactly one source: google or faces.`);
56
+ }
57
+ if (font.google && font.google.weights.length === 0) {
58
+ throw new Error(`Brand font "${font.family}" google source needs at least one weight.`);
59
+ }
60
+ }
61
+ }
62
+
63
+ function buildGoogleFamilyQuery(font: SiteBrandFont): string {
64
+ const google = font.google!;
65
+ const weights = [...new Set(google.weights)].sort((left, right) => left - right);
66
+ const familyName = font.family.trim().replace(/\s+/g, "+");
67
+ if (!google.italics) return `family=${familyName}:wght@${weights.join(";")}`;
68
+ const axisValues = [0, 1].flatMap((italic) => weights.map((weight) => `${italic},${weight}`));
69
+ return `family=${familyName}:ital,wght@${axisValues.join(";")}`;
70
+ }
71
+
72
+ export function buildGoogleFontsHref(fonts: SiteBrandFont[]): string | null {
73
+ const googleFonts = fonts.filter((font) => font.google);
74
+ if (googleFonts.length === 0) return null;
75
+ return `${GOOGLE_STYLESHEET_ORIGIN}/css2?${googleFonts.map(buildGoogleFamilyQuery).join("&")}&display=swap`;
76
+ }
77
+
78
+ function buildFontFaceCss(font: SiteBrandFont): string {
79
+ return (font.faces ?? [])
80
+ .map((face) => {
81
+ const declarations = [
82
+ `font-family:${JSON.stringify(font.family)}`,
83
+ `src:url(${JSON.stringify(face.src)})`,
84
+ `font-weight:${face.weight ?? 400}`,
85
+ `font-style:${face.style ?? "normal"}`,
86
+ "font-display:swap",
87
+ ];
88
+ return `@font-face{${declarations.join(";")}}`;
89
+ })
90
+ .join("");
91
+ }
92
+
93
+ export function resolveBrandFontAssets(fonts: SiteBrandFont[] | undefined): BrandFontAssets {
94
+ if (!fonts || fonts.length === 0) {
95
+ return { preconnectOrigins: [], stylesheetHrefs: [], fontFaceCss: "", rootStyle: {}, isEmpty: true };
96
+ }
97
+ validateBrandFonts(fonts);
98
+ const googleHref = buildGoogleFontsHref(fonts);
99
+ return {
100
+ preconnectOrigins: googleHref ? [GOOGLE_STYLESHEET_ORIGIN, GOOGLE_FILE_ORIGIN] : [],
101
+ stylesheetHrefs: googleHref ? [googleHref] : [],
102
+ fontFaceCss: fonts.map(buildFontFaceCss).join(""),
103
+ rootStyle: Object.fromEntries(
104
+ fonts.map((font) => [font.cssVariable, `${JSON.stringify(font.family)}, ${font.fallback ?? DEFAULT_FALLBACK}`]),
105
+ ),
106
+ isEmpty: false,
107
+ };
108
+ }
109
+
110
+ export function listBrandFontFamilies(fonts: SiteBrandFont[] | undefined): string[] {
111
+ return (fonts ?? []).map((font) => font.family);
112
+ }
@@ -1,6 +1,7 @@
1
1
  import type { Article, ArticleVideoAsset } from "./articles";
2
2
  import type { EditionLayoutPlan } from "./layout-plan";
3
3
  import type { PublicationItem } from "./publication-items";
4
+ import type { VideoScriptRef } from "./video-script";
4
5
 
5
6
  export type ContentSource = "scenario" | "graphql";
6
7
 
@@ -55,6 +56,8 @@ export type EditionContent = {
55
56
  scenarioId?: string;
56
57
  description?: string;
57
58
  editionVideo?: ArticleVideoAsset | null;
59
+ /** Video scripts by item slug (`"edition-overview"` for the edition video), for the VideoML preview. */
60
+ videoScripts?: Record<string, VideoScriptRef>;
58
61
  };
59
62
 
60
63
  export type EditionRouteSummary = {
@@ -939,15 +939,20 @@ function parseAttachmentPointer(value: unknown): ContentAttachmentPointer | null
939
939
  };
940
940
  }
941
941
 
942
- function readInlineExcerpt(editorial: unknown): string | null {
942
+ export function readInlineExcerpt(editorial: unknown): string | null {
943
943
  const parsed = parseObjectMetadata(editorial);
944
944
  if (!parsed) return null;
945
945
  const direct = parsed.excerpt;
946
946
  if (typeof direct === "string" && direct.trim()) return direct.trim();
947
+ const custom = parsed.customExcerpt;
948
+ if (typeof custom === "string" && custom.trim()) return custom.trim();
947
949
  const newsroom = parsed.newsroom;
948
950
  if (!newsroom || typeof newsroom !== "object" || Array.isArray(newsroom)) return null;
949
- const nestedExcerpt = (newsroom as Record<string, unknown>).excerpt;
950
- return typeof nestedExcerpt === "string" && nestedExcerpt.trim() ? nestedExcerpt.trim() : null;
951
+ const newsroomRecord = newsroom as Record<string, unknown>;
952
+ const nestedExcerpt = newsroomRecord.excerpt;
953
+ if (typeof nestedExcerpt === "string" && nestedExcerpt.trim()) return nestedExcerpt.trim();
954
+ const nestedCustom = newsroomRecord.customExcerpt;
955
+ return typeof nestedCustom === "string" && nestedCustom.trim() ? nestedCustom.trim() : null;
951
956
  }
952
957
 
953
958
  function normalizePublicationItemType(value: string): Exclude<PublicationItemType, "article"> | null {
package/lib/renderer.ts CHANGED
@@ -4,6 +4,7 @@ import type { EditionContent } from "./content-types";
4
4
  import type { PretextLayout } from "./renderer-config";
5
5
  import type { PresentationFooterEntry } from "./presentation-footer";
6
6
  import type { PublicationItem } from "./publication-items";
7
+ import type { VideoScriptRef } from "./video-script";
7
8
 
8
9
  /**
9
10
  * Renderer is the swappable render pipeline that turns standard content
@@ -36,6 +37,8 @@ export type RenderEditionProps = {
36
37
  export type ArticlePageEditionFooter = {
37
38
  editionBasePath: string;
38
39
  entries: PresentationFooterEntry[];
40
+ /** Section links for the rhythm blog layout's page header. Ignored by the classic layout. */
41
+ sections?: Array<{ key: string; label: string }>;
39
42
  subtitle: string;
40
43
  title?: string;
41
44
  };
@@ -46,6 +49,8 @@ export type RenderArticleProps = {
46
49
  backLabel?: string;
47
50
  editionFooter?: ArticlePageEditionFooter;
48
51
  editionDate?: string;
52
+ /** VideoML script for the preview player. */
53
+ videoScript?: VideoScriptRef | null;
49
54
  };
50
55
 
51
56
  export type RenderItemProps = {
@@ -54,6 +59,8 @@ export type RenderItemProps = {
54
59
  backLabel?: string;
55
60
  editionFooter?: ArticlePageEditionFooter;
56
61
  editionDate?: string;
62
+ /** VideoML script for the preview player. */
63
+ videoScript?: VideoScriptRef | null;
57
64
  };
58
65
 
59
66
  /**
package/lib/site-brand.ts CHANGED
@@ -2,6 +2,7 @@ import type { ComponentType } from "react";
2
2
  import type { EditionPresentationFormat } from "./content-types";
3
3
  import type { Article, ArticleVideoAsset } from "./articles";
4
4
  import type { BlogPageBackgroundProps } from "../components/blog-page-background";
5
+ import type { SiteBrandFont } from "./brand-fonts";
5
6
  import type { PictogramFigureProps } from "../components/pictogram-figure";
6
7
  import papyrusSite from "papyrus-site";
7
8
  import {
@@ -24,6 +25,23 @@ export type SiteBrandComponents = {
24
25
  BlogPageBackground?: ComponentType<BlogPageBackgroundProps>;
25
26
  };
26
27
 
28
+ /**
29
+ * One line of a brand's header tagline: an emphasized lead and a plain tail,
30
+ * e.g. `{ emphasis: "Practical advice", tail: "for staying secure" }`.
31
+ */
32
+ export type SiteBrandTaglineLine = {
33
+ emphasis: string;
34
+ tail: string;
35
+ };
36
+
37
+ /**
38
+ * Blog presentation layout. `"classic"` (the default) is the original blog
39
+ * layout. `"rhythm"` snaps every height and gap to the vertical rhythm grid,
40
+ * floats featured images on the grid, draws rhythm rules between items, adds
41
+ * the eyebrow/tagline header and the rhythm overlay hotkey (Ctrl+=).
42
+ */
43
+ export type SiteBrandBlogLayout = "classic" | "rhythm";
44
+
27
45
  /** Default scenario content a brand supplies for the layout lab. */
28
46
  export type SiteBrandDemoEdition = {
29
47
  title: string;
@@ -53,6 +71,10 @@ export type SiteBrand = {
53
71
  mastheadTitle: string;
54
72
  mastheadSubtitle: string;
55
73
  mastheadTagline?: string;
74
+ /** Rhythm blog layout only: small line above the masthead title, e.g. "Acme Research". The first word is emphasized. */
75
+ mastheadEyebrow?: string;
76
+ /** Rhythm blog layout only: structured tagline; `mastheadTagline` stays the plain-text fallback. */
77
+ mastheadTaglineLines?: SiteBrandTaglineLine[];
56
78
  backToHomeLabel: string;
57
79
  articleTitleSuffix: string;
58
80
  placeholderByline: string;
@@ -89,6 +111,20 @@ export type SiteBrand = {
89
111
  analysisProfilesPath: string;
90
112
  publicationName: string;
91
113
  components?: SiteBrandComponents;
114
+ /**
115
+ * Video player chrome. `"native"` (default) is the browser's controls;
116
+ * `"framed"` wraps the media in `.article-video__media` and renders a
117
+ * "Play Video" button with a seek bar (`.article-video__cta*`), styled by the publication CSS.
118
+ */
119
+ videoPlayer?: "native" | "framed";
120
+ /** Blog presentation layout. Default `"classic"`. See `SiteBrandBlogLayout`. */
121
+ blogLayout?: SiteBrandBlogLayout;
122
+ /**
123
+ * Brand font slot: families the root layout loads (Google Fonts stylesheet
124
+ * or self-hosted files) and exposes as CSS custom properties. See
125
+ * `lib/brand-fonts.ts` and docs/brand-slots.md.
126
+ */
127
+ fonts?: SiteBrandFont[];
92
128
  demoEdition?: SiteBrandDemoEdition;
93
129
  video?: SiteBrandVideo;
94
130
  };
package/package.json CHANGED
@@ -93,6 +93,8 @@
93
93
  "./components/papyrus-console-shell": "./components/papyrus-console-shell.tsx",
94
94
  "./components/pictogram-figure": "./components/pictogram-figure.tsx",
95
95
  "./components/presentation-footer": "./components/presentation-footer.tsx",
96
+ "./components/presentation-header": "./components/presentation-header.tsx",
97
+ "./components/presentation-rhythm-hrule": "./components/presentation-rhythm-hrule.tsx",
96
98
  "./components/reader-auth-control": "./components/reader-auth-control.tsx",
97
99
  "./components/reader-auth-state": "./components/reader-auth-state.ts",
98
100
  "./components/reader-settings": "./components/reader-settings.ts",
@@ -119,7 +121,10 @@
119
121
  "./components/ui/tabs": "./components/ui/tabs.tsx",
120
122
  "./components/ui/textarea": "./components/ui/textarea.tsx",
121
123
  "./components/ui/tooltip": "./components/ui/tooltip.tsx",
124
+ "./components/use-blog-header-obstacles": "./components/use-blog-header-obstacles.ts",
125
+ "./components/use-brand-fonts-ready": "./components/use-brand-fonts-ready.ts",
122
126
  "./components/use-resolved-papyrus-theme": "./components/use-resolved-papyrus-theme.ts",
127
+ "./components/use-rhythm-overlay": "./components/use-rhythm-overlay.ts",
123
128
  "./lib/amplify-outputs-guard": "./lib/amplify-outputs-guard.ts",
124
129
  "./lib/amplify-outputs-path": "./lib/amplify-outputs-path.ts",
125
130
  "./lib/amplify-server-runtime": "./lib/amplify-server-runtime.ts",
@@ -128,6 +133,7 @@
128
133
  "./lib/articles": "./lib/articles.ts",
129
134
  "./lib/blog-feature-solver": "./lib/blog-feature-solver.ts",
130
135
  "./lib/blog-rhythm": "./lib/blog-rhythm.ts",
136
+ "./lib/brand-fonts": "./lib/brand-fonts.ts",
131
137
  "./lib/cached-content-repository": "./lib/cached-content-repository.ts",
132
138
  "./lib/category-dashboard": "./lib/category-dashboard.ts",
133
139
  "./lib/category-repository": "./lib/category-repository.ts",
@@ -211,7 +217,11 @@
211
217
  "./renderers/pretext/article-page": "./renderers/pretext/article-page.tsx",
212
218
  "./renderers/pretext/index": "./renderers/pretext/index.ts",
213
219
  "./renderers/pretext/layouts": "./renderers/pretext/layouts.ts",
220
+ "./renderers/pretext/presentation-shared": "./renderers/pretext/presentation-shared.tsx",
214
221
  "./renderers/pretext/presentation-shell": "./renderers/pretext/presentation-shell.tsx",
222
+ "./renderers/pretext/rhythm-article-page": "./renderers/pretext/rhythm-article-page.tsx",
223
+ "./renderers/pretext/rhythm-blog-presentation": "./renderers/pretext/rhythm-blog-presentation.tsx",
224
+ "./renderers/pretext/rhythm-shell-style": "./renderers/pretext/rhythm-shell-style.ts",
215
225
  "./app/%5F_preview/[[...path]]/route": "./app/%5F_preview/[[...path]]/route.ts",
216
226
  "./app/[year]/[month]/[day]/[articleSlug]/page": "./app/[year]/[month]/[day]/[articleSlug]/page.tsx",
217
227
  "./app/[year]/[month]/[day]/page": "./app/[year]/[month]/[day]/page.tsx",
@@ -281,7 +291,7 @@
281
291
  "optional": true
282
292
  }
283
293
  },
284
- "version": "1.0.0-next.18",
294
+ "version": "1.0.0-next.19",
285
295
  "dependencies": {
286
296
  "@aws-amplify/adapter-nextjs": "^1.7.3",
287
297
  "@aws-sdk/client-cognito-identity-provider": "3.1045.0",
@@ -9,6 +9,7 @@ import { SITE_BRAND } from "../../lib/site-brand";
9
9
  import { ArticleVideoFigure } from "../../components/article-video";
10
10
  import { PictogramFigure as GenericPictogramFigure } from "../../components/pictogram-figure";
11
11
  import { PresentationFooter } from "../../components/presentation-footer";
12
+ import { RhythmArticlePage } from "./rhythm-article-page";
12
13
 
13
14
  const PictogramFigure = SITE_BRAND.components?.PictogramFigure ?? GenericPictogramFigure;
14
15
 
@@ -16,7 +17,24 @@ export type { ArticlePageEditionFooter };
16
17
 
17
18
  type ArticlePageViewProps = RenderArticleProps;
18
19
 
19
- export function ArticlePageView({ article, backHref, backLabel = SITE_BRAND.backToHomeLabel, editionFooter, editionDate }: ArticlePageViewProps) {
20
+ export function ArticlePageView({ article, backHref, backLabel = SITE_BRAND.backToHomeLabel, editionFooter, editionDate, videoScript = null }: ArticlePageViewProps) {
21
+ if (SITE_BRAND.blogLayout === "rhythm") {
22
+ return (
23
+ <RhythmArticlePage
24
+ body={article.body}
25
+ deck={article.deck}
26
+ editionDate={editionDate}
27
+ editionFooter={editionFooter}
28
+ headline={article.headline}
29
+ image={article.image ?? null}
30
+ section={article.section}
31
+ slug={article.slug}
32
+ video={article.video ?? null}
33
+ videoScript={videoScript}
34
+ />
35
+ );
36
+ }
37
+
20
38
  const articleDate = editionDate ? formatArticleDate(editionDate) : null;
21
39
 
22
40
  return (
@@ -55,9 +73,27 @@ export function ArticlePageView({ article, backHref, backLabel = SITE_BRAND.back
55
73
 
56
74
  type ItemPageViewProps = RenderItemProps;
57
75
 
58
- export function ItemPageView({ item, backHref, backLabel = "Back to edition", editionFooter, editionDate }: ItemPageViewProps) {
76
+ export function ItemPageView({ item, backHref, backLabel = "Back to edition", editionFooter, editionDate, videoScript = null }: ItemPageViewProps) {
59
77
  if (item.type === "article") {
60
- return <ArticlePageView article={item} backHref={backHref} backLabel={backLabel} editionFooter={editionFooter} editionDate={editionDate} />;
78
+ return <ArticlePageView article={item} backHref={backHref} backLabel={backLabel} editionFooter={editionFooter} editionDate={editionDate} videoScript={videoScript} />;
79
+ }
80
+
81
+ if (SITE_BRAND.blogLayout === "rhythm") {
82
+ return (
83
+ <RhythmArticlePage
84
+ body={item.body ?? []}
85
+ deck={item.deck}
86
+ editionDate={editionDate}
87
+ editionFooter={editionFooter}
88
+ headline={item.title}
89
+ image={item.image ?? null}
90
+ itemType={item.type}
91
+ section={item.section ?? item.type}
92
+ slug={item.slug}
93
+ video={getPublicationItemVideoAsset(item) ?? null}
94
+ videoScript={videoScript}
95
+ />
96
+ );
61
97
  }
62
98
 
63
99
  const itemDate = editionDate ? formatArticleDate(editionDate) : null;