@anthusai/papyrus 1.0.0-next.4 → 1.0.0-next.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/amplify/auth/redirect-urls.js +54 -0
  2. package/amplify/auth/redirect-urls.ts +66 -0
  3. package/amplify/auth/resource.js +20 -5
  4. package/amplify/auth/resource.ts +27 -5
  5. package/amplify/data/resource.js +28 -70
  6. package/amplify/data/resource.ts +29 -70
  7. package/amplify/data/schema.js +33 -58
  8. package/amplify/data/schema.ts +34 -58
  9. package/amplify/functions/category-action/handler.ts +5 -3
  10. package/amplify/functions/console-chat-responder/py/execute_tactus_runner.py +0 -10
  11. package/amplify/functions/console-chat-responder/resource.js +0 -7
  12. package/amplify/functions/console-chat-responder/resource.ts +0 -8
  13. package/amplify/functions/console-chat-responder/src/main.rs +0 -9
  14. package/amplify/functions/content-actions/handler.py +45 -3
  15. package/amplify/functions/shared/reference-curation-metadata.ts +29 -0
  16. package/amplify/inbound-email/ses-intake.js +86 -0
  17. package/amplify/inbound-email/ses-intake.ts +110 -0
  18. package/amplify/rebuild-trigger-settings.js +22 -0
  19. package/amplify/rebuild-trigger-settings.ts +42 -0
  20. package/amplify/resolver-waves.js +26 -0
  21. package/amplify/resolver-waves.ts +34 -0
  22. package/amplify/site-backend-config.ts +36 -1
  23. package/amplify/site-backend-names.js +103 -0
  24. package/amplify/site-backend-names.ts +166 -0
  25. package/amplify/site-backend.js +93 -170
  26. package/amplify/site-backend.ts +126 -196
  27. package/amplify/storage/resource.js +3 -0
  28. package/amplify/storage/resource.ts +3 -0
  29. package/amplify/storage-backups/construct.js +31 -0
  30. package/amplify/storage-backups/construct.ts +47 -0
  31. package/app/%5F_preview/[[...path]]/route.ts +8 -0
  32. package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +2 -0
  33. package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -0
  34. package/app/api/media/[...path]/route.ts +5 -16
  35. package/app/api/revalidate/route.ts +8 -7
  36. package/app/articles/[slug]/page.tsx +6 -4
  37. package/app/blog-rhythm.css +278 -0
  38. package/app/globals.css +21 -0
  39. package/app/layout.tsx +16 -0
  40. package/app/newsroom/[section]/[...selection]/page.tsx +6 -5
  41. package/app/newsroom/[section]/page.tsx +4 -3
  42. package/app/newsroom/articles/[id]/page.tsx +15 -0
  43. package/app/newsroom/articles/page.tsx +13 -0
  44. package/app/newsroom/layout.tsx +4 -9
  45. package/app/page.tsx +18 -5
  46. package/app/robots.ts +2 -5
  47. package/bin/papyrus-app.mjs +12 -2
  48. package/bin/papyrus-infra.mjs +29 -3
  49. package/components/amplify-client-provider.tsx +4 -22
  50. package/components/archive-shell.tsx +2 -22
  51. package/components/article-video.tsx +247 -3
  52. package/components/blog-page-background.tsx +23 -6
  53. package/components/markus-ir-preview.tsx +300 -0
  54. package/components/news-desk-taxonomy-client.ts +51 -0
  55. package/components/newspaper.tsx +6 -2
  56. package/components/newsroom-access-gate.tsx +67 -0
  57. package/components/newsroom-article-editor.tsx +741 -0
  58. package/components/newsroom-articles-view.tsx +129 -0
  59. package/components/newsroom-client-shell.tsx +12 -0
  60. package/components/newsroom-ops-overview.tsx +28 -11
  61. package/components/newsroom-ops-shell.tsx +3 -1
  62. package/components/newsroom-references-view.tsx +83 -8
  63. package/components/papyrus-console-shell.tsx +4 -2
  64. package/components/pictogram-figure.tsx +9 -2
  65. package/components/presentation-header.tsx +135 -0
  66. package/components/presentation-rhythm-hrule.tsx +17 -0
  67. package/components/reference-cyclotron-status.tsx +109 -0
  68. package/components/reference-relevance-review.tsx +75 -0
  69. package/components/settings-page.tsx +2 -1
  70. package/components/topic-steering-workspace.tsx +54 -64
  71. package/components/use-blog-header-obstacles.ts +81 -0
  72. package/components/use-brand-fonts-ready.ts +28 -0
  73. package/components/use-rhythm-overlay.ts +29 -0
  74. package/infra/amplify-app-shell.js +212 -50
  75. package/infra/amplify-app-shell.ts +240 -53
  76. package/infra/build-specs.js +14 -5
  77. package/infra/build-specs.ts +17 -7
  78. package/infra/github-oidc-provider.js +24 -0
  79. package/infra/github-oidc-provider.ts +30 -0
  80. package/infra/index.js +1 -0
  81. package/infra/site-config.js +167 -10
  82. package/infra/site-config.ts +196 -14
  83. package/lib/amplify-outputs-path.ts +5 -1
  84. package/lib/blog-feature-solver.ts +459 -0
  85. package/lib/blog-rhythm.ts +134 -0
  86. package/lib/brand-fonts-watch.ts +43 -0
  87. package/lib/brand-fonts.ts +112 -0
  88. package/lib/cached-content-repository.ts +12 -0
  89. package/lib/category-repository.ts +42 -93
  90. package/lib/content-actions-demo.ts +190 -0
  91. package/lib/content-repository.ts +3 -0
  92. package/lib/content-source-context.ts +12 -2
  93. package/lib/content-types.ts +4 -0
  94. package/lib/edition-routes.ts +31 -29
  95. package/lib/graphql-content-repository.ts +61 -9
  96. package/lib/media-route-response.ts +45 -0
  97. package/lib/newspaper-layout.ts +3 -2
  98. package/lib/newsroom-articles.ts +325 -0
  99. package/lib/newsroom-base-path.ts +12 -3
  100. package/lib/newsroom-category-drilldown.ts +3 -2
  101. package/lib/newsroom-demo-articles.ts +90 -0
  102. package/lib/newsroom-forum-routes.ts +6 -4
  103. package/lib/newsroom-index-filters.ts +14 -10
  104. package/lib/newsroom-media-backend.ts +142 -0
  105. package/lib/newsroom-media.ts +182 -0
  106. package/lib/newsroom-nav.ts +3 -0
  107. package/lib/oauth-redirect-priority.ts +40 -0
  108. package/lib/papyrus-web-locations.ts +7 -6
  109. package/lib/presentation-footer.ts +3 -2
  110. package/lib/pretext-layout.ts +12 -3
  111. package/lib/reader-base-path.ts +24 -0
  112. package/lib/reader-static-params.ts +13 -0
  113. package/lib/reference-policy.ts +5 -0
  114. package/lib/relevance-decisions.ts +103 -0
  115. package/lib/relevance-status.ts +63 -0
  116. package/lib/renderer.ts +7 -0
  117. package/lib/revalidate-secret.ts +59 -0
  118. package/lib/robots-policy.ts +14 -0
  119. package/lib/root-route-redirect.ts +16 -0
  120. package/lib/semantic-graph.ts +7 -0
  121. package/lib/site-brand.ts +51 -0
  122. package/lib/staging-gated-path.ts +23 -0
  123. package/lib/staging-preview-object.ts +114 -0
  124. package/lib/video-mode.ts +42 -0
  125. package/lib/video-script.ts +77 -0
  126. package/middleware.ts +23 -18
  127. package/package.json +45 -1
  128. package/renderers/pretext/article-page.tsx +39 -3
  129. package/renderers/pretext/presentation-shared.tsx +113 -0
  130. package/renderers/pretext/presentation-shell.tsx +15 -102
  131. package/renderers/pretext/rhythm-article-page.tsx +165 -0
  132. package/renderers/pretext/rhythm-blog-presentation.tsx +279 -0
  133. package/renderers/pretext/rhythm-shell-style.ts +35 -0
  134. package/routes.manifest.json +45 -2
  135. package/scripts/videoml/build-browser-bundle.mjs +22 -0
  136. package/scripts/videoml/build-preview-bundle.mjs +50 -0
  137. package/scripts/videoml/bundle-common.mjs +129 -0
  138. package/scripts/videoml/preview/preview-entry.tsx +381 -0
  139. package/scripts/videoml/preview/preview-stage-fit.ts +28 -0
  140. package/scripts/videoml/preview/preview-timing.ts +42 -0
  141. package/scripts/videoml/preview.html +77 -0
  142. package/scripts/videoml/standard-entry.tsx +46 -0
  143. package/src/sync.mjs +30 -6
  144. package/src/with-papyrus.mjs +19 -2
  145. package/amplify/functions/graphql-jwt-authorizer/handler.ts +0 -193
  146. package/amplify/functions/graphql-jwt-authorizer/resource.js +0 -14
  147. package/amplify/functions/graphql-jwt-authorizer/resource.ts +0 -15
@@ -0,0 +1,165 @@
1
+ "use client";
2
+
3
+ import { useEffect, useMemo, useRef, useState, type RefObject } from "react";
4
+ import type { ArticleImage, ArticleVideoAsset } from "../../lib/articles";
5
+ import { solveFeaturedFloatGeometry } from "../../lib/blog-feature-solver";
6
+ import type { ArticlePageEditionFooter } from "../../lib/renderer";
7
+ import { SITE_BRAND } from "../../lib/site-brand";
8
+ import type { VideoScriptRef } from "../../lib/video-script";
9
+ import { ArticleVideoFigure } from "../../components/article-video";
10
+ import { BlogPageBackground as GenericBlogPageBackground } from "../../components/blog-page-background";
11
+ import { PictogramFigure as GenericPictogramFigure } from "../../components/pictogram-figure";
12
+ import { PresentationFooter } from "../../components/presentation-footer";
13
+ import { PresentationHeader } from "../../components/presentation-header";
14
+ import { useBlogHeaderObstacles } from "../../components/use-blog-header-obstacles";
15
+ import { useRhythmOverlay } from "../../components/use-rhythm-overlay";
16
+ import { BLOG_RHYTHM, getFeaturedLayoutStyle, getRhythmShellStyle } from "./rhythm-shell-style";
17
+
18
+ const BlogPageBackground = SITE_BRAND.components?.BlogPageBackground ?? GenericBlogPageBackground;
19
+ const PictogramFigure = SITE_BRAND.components?.PictogramFigure ?? GenericPictogramFigure;
20
+
21
+ export type RhythmArticlePageProps = {
22
+ body: string[];
23
+ deck?: string;
24
+ editionDate?: string;
25
+ editionFooter?: ArticlePageEditionFooter;
26
+ headline: string;
27
+ image: ArticleImage | null;
28
+ itemType?: string;
29
+ section: string;
30
+ slug: string;
31
+ video: ArticleVideoAsset | null;
32
+ videoScript: VideoScriptRef | null;
33
+ };
34
+
35
+ export function RhythmArticlePage({
36
+ body,
37
+ deck,
38
+ editionDate,
39
+ editionFooter,
40
+ headline,
41
+ image,
42
+ itemType,
43
+ section,
44
+ slug,
45
+ video,
46
+ videoScript,
47
+ }: RhythmArticlePageProps) {
48
+ const articleRef = useRef<HTMLElement | null>(null);
49
+ const pageRef = useRef<HTMLElement | null>(null);
50
+ const containerWidth = useMeasuredWidth(articleRef);
51
+ const viewportWidth = useViewportWidth();
52
+ const showRhythmOverlay = useRhythmOverlay();
53
+ const headerObstacles = useBlogHeaderObstacles(pageRef, BLOG_RHYTHM.paintBuffer);
54
+ const floatGeometry = useMemo(() => {
55
+ if (!image || !containerWidth) return null;
56
+ return solveFeaturedFloatGeometry({
57
+ containerWidth,
58
+ viewportWidth,
59
+ rhythm: BLOG_RHYTHM,
60
+ imageAsset: image,
61
+ itemIndex: 0,
62
+ });
63
+ }, [containerWidth, image, viewportWidth]);
64
+
65
+ const hasImage = Boolean(image);
66
+ const articleClassName = hasImage ? "article-page article-float-grid" : "article-page";
67
+
68
+ return (
69
+ <main
70
+ className={`${editionFooter ? "article-shell article-shell--edition" : "article-shell"} blog-rhythm-shell`}
71
+ data-rhythm-overlay={showRhythmOverlay ? "true" : "false"}
72
+ ref={pageRef}
73
+ style={getRhythmShellStyle(BLOG_RHYTHM)}
74
+ >
75
+ <BlogPageBackground headerObstacles={headerObstacles} pageRef={pageRef} rhythm={BLOG_RHYTHM} />
76
+ <PresentationHeader
77
+ editionBasePath={editionFooter?.editionBasePath}
78
+ editionDate={editionDate}
79
+ sections={editionFooter?.sections}
80
+ />
81
+ <article
82
+ className={articleClassName}
83
+ data-feature-layout={hasImage ? (floatGeometry?.mode ?? "float") : undefined}
84
+ data-has-image={hasImage ? "true" : "false"}
85
+ data-item-type={itemType}
86
+ ref={articleRef}
87
+ style={floatGeometry ? getFeaturedLayoutStyle(floatGeometry) : undefined}
88
+ >
89
+ {video ? (
90
+ <div className="article-page__hero-video">
91
+ <ArticleVideoFigure slug={slug} video={video} videoScript={videoScript} />
92
+ </div>
93
+ ) : null}
94
+ <header className={hasImage ? "article-float-grid__header" : undefined}>
95
+ <p className="story-label">{section}</p>
96
+ <h1>{headline}</h1>
97
+ {deck ? <p className="article-deck">{deck}</p> : null}
98
+ </header>
99
+ {image ? (
100
+ <div className="presentation-item__media article-float-grid__media">
101
+ <PictogramFigure
102
+ alt={image.alt}
103
+ caption={image.caption}
104
+ credit={image.credit}
105
+ figureClassName="presentation-item__image"
106
+ frameHeight={floatGeometry?.imageHeight}
107
+ frameWidth={floatGeometry?.imageWidth}
108
+ height={760}
109
+ layout={image.layout}
110
+ priority
111
+ sizes="(max-width: 900px) 100vw, 760px"
112
+ slug={slug}
113
+ src={image.src}
114
+ themeVariants={image.themeVariants}
115
+ width={1200}
116
+ />
117
+ </div>
118
+ ) : null}
119
+ <div className={hasImage ? "article-body article-float-grid__body" : "article-body"}>
120
+ {body.map((paragraph) => (
121
+ <p key={paragraph}>{paragraph}</p>
122
+ ))}
123
+ </div>
124
+ </article>
125
+ {editionFooter ? (
126
+ <PresentationFooter
127
+ editionBasePath={editionFooter.editionBasePath}
128
+ entries={editionFooter.entries}
129
+ resolveSectionHref={(entry) => `${editionFooter.editionBasePath}#section-${entry.sectionKey}`}
130
+ subtitle={editionFooter.subtitle}
131
+ title={editionFooter.title}
132
+ />
133
+ ) : null}
134
+ </main>
135
+ );
136
+ }
137
+
138
+ function useMeasuredWidth(ref: RefObject<HTMLElement | null>): number {
139
+ const [width, setWidth] = useState(0);
140
+ useEffect(() => {
141
+ const node = ref.current;
142
+ if (!node) return;
143
+ const update = () => setWidth(Math.max(1, Math.floor(node.getBoundingClientRect().width)));
144
+ update();
145
+ const observer = new ResizeObserver(update);
146
+ observer.observe(node);
147
+ window.addEventListener("resize", update);
148
+ return () => {
149
+ observer.disconnect();
150
+ window.removeEventListener("resize", update);
151
+ };
152
+ }, [ref]);
153
+ return width;
154
+ }
155
+
156
+ function useViewportWidth(): number {
157
+ const [width, setWidth] = useState(0);
158
+ useEffect(() => {
159
+ const update = () => setWidth(Math.max(1, Math.floor(window.innerWidth)));
160
+ update();
161
+ window.addEventListener("resize", update);
162
+ return () => window.removeEventListener("resize", update);
163
+ }, []);
164
+ return width;
165
+ }
@@ -0,0 +1,279 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { useEffect, useMemo, useRef, useState } from "react";
5
+ import { solveFeaturedItem } from "../../lib/blog-feature-solver";
6
+ import { createDefaultBlogTextStyle, getMeasuredTextHeight, reserveRhythmRows } from "../../lib/blog-rhythm";
7
+ import type { ArticleVideoAsset } from "../../lib/articles";
8
+ import type { EditionContent, EditionSection } from "../../lib/content-types";
9
+ import { getEditionSectionItems } from "../../lib/edition-sections";
10
+ import { layoutAllTextLines, prepareWithSegments } from "../../lib/pretext-layout";
11
+ import { buildPresentationFooterEntries } from "../../lib/presentation-footer";
12
+ import {
13
+ getPublicationItemImageAssets,
14
+ getPublicationItemVideoAsset,
15
+ type PublicationItem,
16
+ } from "../../lib/publication-items";
17
+ import { SITE_BRAND } from "../../lib/site-brand";
18
+ import { EDITION_OVERVIEW_VIDEO_KEY, type VideoScriptRef } from "../../lib/video-script";
19
+ import { ArticleVideoFigure } from "../../components/article-video";
20
+ import { BlogPageBackground as GenericBlogPageBackground } from "../../components/blog-page-background";
21
+ import { PictogramFigure as GenericPictogramFigure } from "../../components/pictogram-figure";
22
+ import { PresentationFooter } from "../../components/presentation-footer";
23
+ import { getSectionAnchorId, PresentationHeader } from "../../components/presentation-header";
24
+ import { PresentationRhythmHRule } from "../../components/presentation-rhythm-hrule";
25
+ import { useBlogHeaderObstacles } from "../../components/use-blog-header-obstacles";
26
+ import { useBrandFontsReadyVersion } from "../../components/use-brand-fonts-ready";
27
+ import { useRhythmOverlay } from "../../components/use-rhythm-overlay";
28
+ import {
29
+ getBlogFooterSectionHref,
30
+ getPresentationBodyText,
31
+ getPresentationItemRole,
32
+ getPresentationTitle,
33
+ handleBlogFooterSectionClick,
34
+ MeasuredPresentationLines,
35
+ useMeasuredWidth,
36
+ usePresentationTargetScroll,
37
+ } from "./presentation-shared";
38
+ import { BLOG_RHYTHM, getFeaturedLayoutStyle, getRhythmShellStyle } from "./rhythm-shell-style";
39
+
40
+ const BlogPageBackground = SITE_BRAND.components?.BlogPageBackground ?? GenericBlogPageBackground;
41
+ const PictogramFigure = SITE_BRAND.components?.PictogramFigure ?? GenericPictogramFigure;
42
+ const BLOG_TEXT_STYLE = createDefaultBlogTextStyle(SITE_BRAND.textFont);
43
+ const SECONDARY_PAIR_ITEM_INDEX = 2;
44
+
45
+ type PapyrusTestWindow = Window & typeof globalThis & {
46
+ __PAPYRUS_SCENARIO__?: string;
47
+ };
48
+
49
+ export function RhythmBlogPresentation({
50
+ content,
51
+ editionBasePath,
52
+ targetSection,
53
+ }: {
54
+ content: EditionContent;
55
+ editionBasePath?: string;
56
+ targetSection?: EditionSection;
57
+ }) {
58
+ const footerEntries = useMemo(() => buildPresentationFooterEntries(content), [content]);
59
+ const footerSubtitle = SITE_BRAND.id === "papyrus" ? (content.description?.trim() || "Inside Papyrus") : SITE_BRAND.mastheadSubtitle;
60
+ const pageRef = useRef<HTMLElement | null>(null);
61
+ const showRhythmOverlay = useRhythmOverlay();
62
+ const headerObstacles = useBlogHeaderObstacles(pageRef, BLOG_RHYTHM.paintBuffer);
63
+ usePresentationTargetScroll(targetSection);
64
+ useEffect(() => {
65
+ (window as PapyrusTestWindow).__PAPYRUS_SCENARIO__ = content.scenarioId;
66
+ return () => {
67
+ delete (window as PapyrusTestWindow).__PAPYRUS_SCENARIO__;
68
+ };
69
+ }, [content.scenarioId]);
70
+
71
+ return (
72
+ <main
73
+ className="presentation-page presentation-page--blog blog-rhythm-shell"
74
+ data-presentation-engine="blog"
75
+ data-rhythm-overlay={showRhythmOverlay ? "true" : "false"}
76
+ ref={pageRef}
77
+ style={getRhythmShellStyle(BLOG_RHYTHM)}
78
+ >
79
+ <BlogPageBackground headerObstacles={headerObstacles} pageRef={pageRef} rhythm={BLOG_RHYTHM} />
80
+ <PresentationHeader
81
+ description={content.description}
82
+ editionBasePath={editionBasePath}
83
+ editionDate={content.editionDate}
84
+ sections={content.sections}
85
+ title={content.title}
86
+ />
87
+ <div className="blog-sections">
88
+ {content.sections.map((section, sectionIndex) => (
89
+ <section className="blog-section" data-edition-section={section.key} id={getSectionAnchorId(section.key)} key={section.key}>
90
+ {sectionIndex === 0 && content.editionVideo ? (
91
+ <EditionOverviewVideo
92
+ editionVideo={content.editionVideo}
93
+ videoScript={content.videoScripts?.[EDITION_OVERVIEW_VIDEO_KEY] ?? null}
94
+ />
95
+ ) : null}
96
+ <header className="presentation-section-header">
97
+ <div className="presentation-section-header__band">
98
+ <p>{section.label}</p>
99
+ </div>
100
+ {section.description ? <span>{section.description}</span> : null}
101
+ </header>
102
+ {getEditionSectionItems(section, content.items).flatMap((item, index) => [
103
+ ...(index > 0
104
+ ? [
105
+ <PresentationRhythmHRule
106
+ hideInSecondaryPair={index === SECONDARY_PAIR_ITEM_INDEX}
107
+ key={`hrule-before-${item.slug}`}
108
+ />,
109
+ ]
110
+ : []),
111
+ <RhythmPresentationItem
112
+ editionBasePath={editionBasePath}
113
+ index={index}
114
+ item={item}
115
+ key={item.slug}
116
+ videoScript={content.videoScripts?.[item.slug] ?? null}
117
+ />,
118
+ ])}
119
+ </section>
120
+ ))}
121
+ </div>
122
+ <PresentationFooter
123
+ editionBasePath={editionBasePath}
124
+ entries={footerEntries}
125
+ onSectionClick={handleBlogFooterSectionClick}
126
+ resolveSectionHref={(entry) => getBlogFooterSectionHref(entry, editionBasePath)}
127
+ subtitle={SITE_BRAND.footerSubtitleOverride ?? footerSubtitle}
128
+ title={SITE_BRAND.footerTitle}
129
+ />
130
+ </main>
131
+ );
132
+ }
133
+
134
+ function EditionOverviewVideo({
135
+ editionVideo,
136
+ videoScript,
137
+ }: {
138
+ editionVideo: ArticleVideoAsset;
139
+ videoScript: VideoScriptRef | null;
140
+ }) {
141
+ return (
142
+ <section className="presentation-edition-video" aria-label={editionVideo.alt}>
143
+ <ArticleVideoFigure
144
+ figureClassName="presentation-edition-video__figure article-video"
145
+ slug={EDITION_OVERVIEW_VIDEO_KEY}
146
+ video={editionVideo}
147
+ videoScript={videoScript}
148
+ />
149
+ </section>
150
+ );
151
+ }
152
+
153
+ function RhythmPresentationItem({
154
+ editionBasePath,
155
+ index,
156
+ item,
157
+ videoScript,
158
+ }: {
159
+ editionBasePath?: string;
160
+ index: number;
161
+ item: PublicationItem;
162
+ videoScript: VideoScriptRef | null;
163
+ }) {
164
+ const articleRef = useRef<HTMLElement | null>(null);
165
+ const frameRef = useRef<HTMLDivElement | null>(null);
166
+ const containerWidth = useMeasuredWidth(articleRef);
167
+ const frameWidth = useMeasuredWidth(frameRef);
168
+ const viewportWidth = useViewportWidth();
169
+ const fontsReadyVersion = useBrandFontsReadyVersion();
170
+ const textStyle = useMemo(() => ({ ...BLOG_TEXT_STYLE, fontsReadyVersion }), [fontsReadyVersion]);
171
+ const image = getPublicationItemImageAssets(item)[0];
172
+ const video = getPublicationItemVideoAsset(item);
173
+ const text = getPresentationBodyText(item, "blog");
174
+ const hasImage = Boolean(image);
175
+ const featuredLayout = useMemo(() => {
176
+ if (!hasImage || !containerWidth || !image) return null;
177
+ return solveFeaturedItem({
178
+ text,
179
+ containerWidth,
180
+ viewportWidth,
181
+ rhythm: BLOG_RHYTHM,
182
+ textStyle,
183
+ imageAsset: image,
184
+ itemIndex: index,
185
+ });
186
+ }, [containerWidth, hasImage, image, index, text, textStyle, viewportWidth]);
187
+ const lines = useMemo(() => {
188
+ if (featuredLayout) return featuredLayout.textLines;
189
+ const maxWidth = frameWidth || containerWidth;
190
+ if (!maxWidth || !text.trim()) return [];
191
+ return layoutAllTextLines({
192
+ prepared: prepareWithSegments(text, `${textStyle.fontSize}px ${textStyle.fontFamily}`, { whiteSpace: "pre-wrap" }),
193
+ maxWidth,
194
+ ...textStyle,
195
+ });
196
+ }, [containerWidth, featuredLayout, frameWidth, text, textStyle]);
197
+ const textHeight = featuredLayout?.textFrameHeight ?? reserveRhythmRows(getMeasuredTextHeight(lines), BLOG_RHYTHM);
198
+ const layoutMode = featuredLayout?.mode ?? "stacked";
199
+ const directHref = editionBasePath ? `${editionBasePath}/${encodeURIComponent(item.slug)}` : `/articles/${encodeURIComponent(item.slug)}`;
200
+
201
+ return (
202
+ <article
203
+ className="presentation-item presentation-item--blog"
204
+ data-feature-layout={hasImage ? layoutMode : undefined}
205
+ data-has-image={hasImage ? "true" : "false"}
206
+ data-has-video={video ? "true" : "false"}
207
+ data-item-id={item.slug}
208
+ data-item-index={index}
209
+ data-item-role={getPresentationItemRole("blog", index)}
210
+ data-item-type={item.type}
211
+ id={item.slug}
212
+ ref={articleRef}
213
+ style={featuredLayout ? getFeaturedLayoutStyle(featuredLayout) : undefined}
214
+ >
215
+ <div className="presentation-item__copy">
216
+ <header className="presentation-item__header">
217
+ <p>{item.section ?? "General"}</p>
218
+ <h2>
219
+ <Link href={directHref}>{getPresentationTitle(item)}</Link>
220
+ </h2>
221
+ {item.deck ? <span>{item.deck}</span> : null}
222
+ </header>
223
+ <div className="presentation-item__body">
224
+ {image ? (
225
+ <div className="presentation-item__media">
226
+ <PictogramFigure
227
+ alt={image.alt}
228
+ caption={image.caption}
229
+ credit={image.credit}
230
+ figureClassName="presentation-item__image"
231
+ frameHeight={featuredLayout?.imageHeight}
232
+ frameWidth={featuredLayout?.imageWidth}
233
+ height={760}
234
+ layout={image.layout}
235
+ sizes="(max-width: 900px) 100vw, 760px"
236
+ slug={item.slug}
237
+ src={image.src}
238
+ themeVariants={image.themeVariants}
239
+ width={1200}
240
+ />
241
+ </div>
242
+ ) : null}
243
+ <div
244
+ className="presentation-item__text-frame"
245
+ data-layout-mode={layoutMode}
246
+ ref={frameRef}
247
+ style={{ height: textHeight }}
248
+ >
249
+ <MeasuredPresentationLines lines={lines} />
250
+ </div>
251
+ <Link className="presentation-item__cta" href={directHref}>
252
+ Read Article
253
+ </Link>
254
+ </div>
255
+ </div>
256
+ {video ? (
257
+ <div className="presentation-item__video">
258
+ <ArticleVideoFigure
259
+ figureClassName="presentation-item__video-figure article-video"
260
+ slug={item.slug}
261
+ video={video}
262
+ videoScript={videoScript}
263
+ />
264
+ </div>
265
+ ) : null}
266
+ </article>
267
+ );
268
+ }
269
+
270
+ function useViewportWidth(): number {
271
+ const [width, setWidth] = useState(0);
272
+ useEffect(() => {
273
+ const update = () => setWidth(Math.max(1, Math.floor(window.innerWidth)));
274
+ update();
275
+ window.addEventListener("resize", update);
276
+ return () => window.removeEventListener("resize", update);
277
+ }, []);
278
+ return width;
279
+ }
@@ -0,0 +1,35 @@
1
+ import type { CSSProperties } from "react";
2
+ import {
3
+ BLOG_COPY_ROW_MULTIPLE,
4
+ createDefaultBlogVerticalRhythm,
5
+ type VerticalRhythm,
6
+ } from "../../lib/blog-rhythm";
7
+
8
+ export const BLOG_RHYTHM: VerticalRhythm = createDefaultBlogVerticalRhythm();
9
+
10
+ export function getRhythmShellStyle(rhythm: VerticalRhythm): CSSProperties {
11
+ return {
12
+ "--blog-rhythm": `${rhythm.rowHeight / BLOG_COPY_ROW_MULTIPLE}px`,
13
+ "--blog-row-height": `${rhythm.rowHeight}px`,
14
+ "--blog-paint-buffer": `${rhythm.paintBuffer}px`,
15
+ "--blog-paint-height": `${rhythm.paintHeight}px`,
16
+ } as CSSProperties;
17
+ }
18
+
19
+ export function getFeaturedLayoutStyle(layout: {
20
+ copyWidth: number;
21
+ imageWidth: number;
22
+ imageHeight: number;
23
+ gap: number;
24
+ mediaHeight?: number;
25
+ textFrameHeight?: number;
26
+ }): CSSProperties {
27
+ return {
28
+ "--feature-copy-width": `${layout.copyWidth}px`,
29
+ "--feature-image-width": `${layout.imageWidth}px`,
30
+ "--feature-image-height": `${layout.imageHeight}px`,
31
+ "--feature-layout-gap": `${layout.gap}px`,
32
+ ...(layout.mediaHeight === undefined ? {} : { "--feature-media-height": `${layout.mediaHeight}px` }),
33
+ ...(layout.textFrameHeight === undefined ? {} : { "--feature-text-frame-height": `${layout.textFrameHeight}px` }),
34
+ } as CSSProperties;
35
+ }
@@ -1,8 +1,19 @@
1
1
  {
2
- "version": "1.0.0-next.4",
2
+ "version": "1.0.0-next.40",
3
3
  "routes": [
4
+ {
5
+ "file": "app/%5F_preview/[[...path]]/route.ts",
6
+ "hasDefault": false,
7
+ "exports": [
8
+ "GET"
9
+ ],
10
+ "config": {
11
+ "dynamic": "\"force-dynamic\""
12
+ }
13
+ },
4
14
  {
5
15
  "file": "app/[year]/[month]/[day]/[articleSlug]/page.tsx",
16
+ "reader": true,
6
17
  "hasDefault": true,
7
18
  "exports": [
8
19
  "generateMetadata"
@@ -13,6 +24,7 @@
13
24
  },
14
25
  {
15
26
  "file": "app/[year]/[month]/[day]/page.tsx",
27
+ "reader": true,
16
28
  "hasDefault": true,
17
29
  "exports": [
18
30
  "generateStaticParams"
@@ -23,6 +35,7 @@
23
35
  },
24
36
  {
25
37
  "file": "app/[year]/[month]/[day]/page/[pageNumber]/page.tsx",
38
+ "reader": true,
26
39
  "hasDefault": true,
27
40
  "exports": [],
28
41
  "config": {
@@ -31,6 +44,7 @@
31
44
  },
32
45
  {
33
46
  "file": "app/[year]/[month]/[day]/section/[sectionKey]/page.tsx",
47
+ "reader": true,
34
48
  "hasDefault": true,
35
49
  "exports": [],
36
50
  "config": {
@@ -69,6 +83,7 @@
69
83
  },
70
84
  {
71
85
  "file": "app/archive/page.tsx",
86
+ "reader": true,
72
87
  "hasDefault": true,
73
88
  "exports": [],
74
89
  "config": {
@@ -77,6 +92,7 @@
77
92
  },
78
93
  {
79
94
  "file": "app/articles/[slug]/page.tsx",
95
+ "reader": true,
80
96
  "hasDefault": true,
81
97
  "exports": [
82
98
  "generateMetadata",
@@ -110,6 +126,22 @@
110
126
  "dynamic": "\"force-dynamic\""
111
127
  }
112
128
  },
129
+ {
130
+ "file": "app/newsroom/articles/[id]/page.tsx",
131
+ "hasDefault": true,
132
+ "exports": [],
133
+ "config": {
134
+ "dynamic": "\"force-dynamic\""
135
+ }
136
+ },
137
+ {
138
+ "file": "app/newsroom/articles/page.tsx",
139
+ "hasDefault": true,
140
+ "exports": [],
141
+ "config": {
142
+ "dynamic": "\"force-dynamic\""
143
+ }
144
+ },
113
145
  {
114
146
  "file": "app/newsroom/forum/[threadId]/page.tsx",
115
147
  "hasDefault": true,
@@ -158,6 +190,15 @@
158
190
  },
159
191
  {
160
192
  "file": "app/page.tsx",
193
+ "reader": true,
194
+ "hasDefault": true,
195
+ "exports": [],
196
+ "config": {
197
+ "dynamic": "\"force-dynamic\""
198
+ }
199
+ },
200
+ {
201
+ "file": "app/robots.ts",
161
202
  "hasDefault": true,
162
203
  "exports": [],
163
204
  "config": {
@@ -166,6 +207,7 @@
166
207
  },
167
208
  {
168
209
  "file": "app/settings/page.tsx",
210
+ "reader": true,
169
211
  "hasDefault": true,
170
212
  "exports": [
171
213
  "metadata"
@@ -177,6 +219,7 @@
177
219
  ],
178
220
  "middleware": {
179
221
  "file": "middleware.ts",
180
- "matcher": "[\"/((?!_next/static|_next/image|favicon.ico|icon).*)\"]"
222
+ "matcher": "[\"/((?!_next/static|_next/image|favicon.ico|icon).*)\"]",
223
+ "runtime": "nodejs"
181
224
  }
182
225
  }
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env node
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { bundleVideoEntries, loadBrandVideoSlot, standardEntryPath } from "./bundle-common.mjs";
5
+
6
+ export async function buildBrowserBundle(projectRoot = process.cwd(), env = process.env) {
7
+ const { brandId, bundleEntry } = await loadBrandVideoSlot(projectRoot, env);
8
+ const result = await bundleVideoEntries({
9
+ projectRoot,
10
+ entries: [standardEntryPath, bundleEntry],
11
+ outfile: path.join(projectRoot, "public/videoml/browser-bundle.js"),
12
+ });
13
+ console.error(`VideoML browser bundle for '${brandId}': ${result.outfile} (${result.size} bytes)`);
14
+ return result;
15
+ }
16
+
17
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
18
+ buildBrowserBundle().catch((error) => {
19
+ console.error(error.message);
20
+ process.exit(1);
21
+ });
22
+ }
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import {
6
+ bundleVideoEntries,
7
+ loadBrandVideoSlot,
8
+ previewEntryPath,
9
+ previewHtmlTemplatePath,
10
+ standardEntryPath,
11
+ } from "./bundle-common.mjs";
12
+
13
+ const REACT_18_VERSION = "18.3.1";
14
+ const VENDOR_FILES = {
15
+ "react.production.min.js": `https://unpkg.com/react@${REACT_18_VERSION}/umd/react.production.min.js`,
16
+ "react-dom.production.min.js": `https://unpkg.com/react-dom@${REACT_18_VERSION}/umd/react-dom.production.min.js`,
17
+ };
18
+
19
+ async function ensureReactVendor(outDirectory) {
20
+ const vendorDirectory = path.join(outDirectory, "vendor");
21
+ fs.mkdirSync(vendorDirectory, { recursive: true });
22
+ for (const [name, url] of Object.entries(VENDOR_FILES)) {
23
+ const target = path.join(vendorDirectory, name);
24
+ if (fs.existsSync(target)) continue;
25
+ const response = await fetch(url);
26
+ if (!response.ok) throw new Error(`Failed to download ${url}: ${response.status} ${response.statusText}`);
27
+ fs.writeFileSync(target, Buffer.from(await response.arrayBuffer()));
28
+ }
29
+ }
30
+
31
+ export async function buildPreviewBundle(projectRoot = process.cwd(), env = process.env) {
32
+ const { brandId, bundleEntry } = await loadBrandVideoSlot(projectRoot, env);
33
+ const outDirectory = path.join(projectRoot, "public/videoml");
34
+ await ensureReactVendor(outDirectory);
35
+ fs.copyFileSync(previewHtmlTemplatePath, path.join(outDirectory, "preview.html"));
36
+ const result = await bundleVideoEntries({
37
+ projectRoot,
38
+ entries: [standardEntryPath, previewEntryPath, bundleEntry],
39
+ outfile: path.join(outDirectory, "preview-bundle.js"),
40
+ });
41
+ console.error(`VideoML preview bundle for '${brandId}': ${result.outfile} (${result.size} bytes)`);
42
+ return result;
43
+ }
44
+
45
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
46
+ buildPreviewBundle().catch((error) => {
47
+ console.error(error.message);
48
+ process.exit(1);
49
+ });
50
+ }