@anthusai/papyrus 1.0.0-next.2 → 1.0.0-next.21

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 (140) hide show
  1. package/amplify/auth/resource.js +17 -4
  2. package/amplify/auth/resource.ts +21 -4
  3. package/amplify/data/resource.js +52 -70
  4. package/amplify/data/resource.ts +57 -70
  5. package/amplify/data/schema.js +52 -58
  6. package/amplify/data/schema.ts +57 -58
  7. package/amplify/functions/console-chat-responder/py/execute_tactus_runner.py +0 -10
  8. package/amplify/functions/console-chat-responder/resource.js +0 -7
  9. package/amplify/functions/console-chat-responder/resource.ts +0 -8
  10. package/amplify/functions/console-chat-responder/src/main.rs +0 -9
  11. package/amplify/functions/content-actions/handler.py +149 -0
  12. package/amplify/functions/content-actions/requirements.txt +4 -0
  13. package/amplify/functions/content-actions/resource.js +40 -0
  14. package/amplify/functions/content-actions/resource.ts +54 -0
  15. package/amplify/functions/shared/python-bundle.js +17 -2
  16. package/amplify/functions/shared/python-bundle.ts +23 -2
  17. package/amplify/rebuild-trigger-settings.js +22 -0
  18. package/amplify/rebuild-trigger-settings.ts +42 -0
  19. package/amplify/site-backend-config.ts +12 -1
  20. package/amplify/site-backend.js +51 -27
  21. package/amplify/site-backend.ts +66 -34
  22. package/amplify/storage/resource.js +3 -0
  23. package/amplify/storage/resource.ts +3 -0
  24. package/app/%5F_preview/[[...path]]/route.ts +8 -0
  25. package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +4 -2
  26. package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -0
  27. package/app/api/revalidate/route.ts +8 -7
  28. package/app/articles/[slug]/page.tsx +6 -4
  29. package/app/blog-rhythm.css +278 -0
  30. package/app/globals.css +22 -4
  31. package/app/layout.tsx +24 -12
  32. package/app/newsroom/articles/[id]/page.tsx +15 -0
  33. package/app/newsroom/articles/page.tsx +13 -0
  34. package/app/page.tsx +12 -4
  35. package/app/robots.ts +11 -0
  36. package/bin/papyrus-app.mjs +12 -2
  37. package/bin/papyrus-infra.mjs +74 -18
  38. package/components/archive-shell.tsx +2 -22
  39. package/components/article-video.tsx +247 -3
  40. package/components/blog-page-background.tsx +23 -6
  41. package/components/markus-ir-preview.tsx +300 -0
  42. package/components/news-desk-page.tsx +1 -4
  43. package/components/newspaper.tsx +6 -2
  44. package/components/newsroom-access-gate.tsx +66 -0
  45. package/components/newsroom-article-editor.tsx +741 -0
  46. package/components/newsroom-articles-view.tsx +129 -0
  47. package/components/newsroom-ops-overview.tsx +28 -11
  48. package/components/newsroom-ops-shell.tsx +3 -1
  49. package/components/pictogram-figure.tsx +9 -2
  50. package/components/presentation-header.tsx +135 -0
  51. package/components/presentation-rhythm-hrule.tsx +17 -0
  52. package/components/settings-page.tsx +2 -1
  53. package/components/staging-banner.tsx +11 -0
  54. package/components/topic-steering-workspace.tsx +1 -59
  55. package/components/use-blog-header-obstacles.ts +81 -0
  56. package/components/use-brand-fonts-ready.ts +28 -0
  57. package/components/use-rhythm-overlay.ts +29 -0
  58. package/infra/amplify-app-shell.js +285 -80
  59. package/infra/amplify-app-shell.ts +326 -113
  60. package/infra/build-specs.js +94 -0
  61. package/infra/build-specs.ts +141 -0
  62. package/infra/github-oidc-provider.js +24 -0
  63. package/infra/github-oidc-provider.ts +30 -0
  64. package/infra/index.js +3 -0
  65. package/infra/site-config.js +273 -0
  66. package/infra/site-config.ts +336 -0
  67. package/lib/blog-feature-solver.ts +459 -0
  68. package/lib/blog-rhythm.ts +134 -0
  69. package/lib/brand-fonts-watch.ts +43 -0
  70. package/lib/brand-fonts.ts +112 -0
  71. package/lib/cached-content-repository.ts +14 -1
  72. package/lib/category-repository.ts +1 -93
  73. package/lib/content-actions-client.ts +98 -0
  74. package/lib/content-actions-demo.ts +190 -0
  75. package/lib/content-repository.ts +6 -0
  76. package/lib/content-source-context.ts +26 -0
  77. package/lib/content-types.ts +4 -0
  78. package/lib/edition-routes.ts +31 -29
  79. package/lib/empty-theme.css +1 -0
  80. package/lib/graphql-content-repository.ts +239 -43
  81. package/lib/layout-scenarios.ts +8 -8
  82. package/lib/newspaper-layout.ts +3 -2
  83. package/lib/newsroom-articles.ts +325 -0
  84. package/lib/newsroom-demo-articles.ts +90 -0
  85. package/lib/newsroom-media-backend.ts +142 -0
  86. package/lib/newsroom-media.ts +182 -0
  87. package/lib/newsroom-nav.ts +3 -0
  88. package/lib/papyrus-web-locations.ts +7 -6
  89. package/lib/presentation-footer.ts +3 -2
  90. package/lib/pretext-layout.ts +12 -3
  91. package/lib/reader-base-path.ts +24 -0
  92. package/lib/reader-static-params.ts +8 -0
  93. package/lib/renderer.ts +7 -0
  94. package/lib/revalidate-secret.ts +59 -0
  95. package/lib/root-route-redirect.ts +16 -0
  96. package/lib/site-brand-tenant.ts +3 -22
  97. package/lib/site-brand.ts +95 -42
  98. package/lib/site-env.ts +52 -0
  99. package/lib/staging-gate.ts +37 -0
  100. package/lib/staging-gated-path.ts +22 -0
  101. package/lib/staging-preview-object.ts +98 -0
  102. package/lib/video-mode.ts +42 -0
  103. package/lib/video-script.ts +77 -0
  104. package/middleware.ts +56 -42
  105. package/package.json +40 -1
  106. package/renderers/pretext/article-page.tsx +40 -9
  107. package/renderers/pretext/presentation-shared.tsx +113 -0
  108. package/renderers/pretext/presentation-shell.tsx +18 -112
  109. package/renderers/pretext/rhythm-article-page.tsx +165 -0
  110. package/renderers/pretext/rhythm-blog-presentation.tsx +279 -0
  111. package/renderers/pretext/rhythm-shell-style.ts +35 -0
  112. package/routes.manifest.json +37 -2
  113. package/scripts/videoml/build-browser-bundle.mjs +22 -0
  114. package/scripts/videoml/build-preview-bundle.mjs +50 -0
  115. package/scripts/videoml/bundle-common.mjs +129 -0
  116. package/scripts/videoml/preview/preview-entry.tsx +381 -0
  117. package/scripts/videoml/preview/preview-stage-fit.ts +28 -0
  118. package/scripts/videoml/preview/preview-timing.ts +42 -0
  119. package/scripts/videoml/preview.html +77 -0
  120. package/scripts/videoml/standard-entry.tsx +46 -0
  121. package/src/sync.mjs +30 -6
  122. package/src/with-papyrus.mjs +15 -2
  123. package/amplify/functions/graphql-jwt-authorizer/handler.ts +0 -193
  124. package/amplify/functions/graphql-jwt-authorizer/resource.js +0 -14
  125. package/amplify/functions/graphql-jwt-authorizer/resource.ts +0 -15
  126. package/publications/anth_us/brand.ts +0 -68
  127. package/publications/anth_us/theme.css +0 -66
  128. package/publications/pilobol_us/brand.ts +0 -37
  129. package/publications/pilobol_us/theme.css +0 -140
  130. package/publications/threat_intelligence/blog-defense/graph.ts +0 -636
  131. package/publications/threat_intelligence/blog-defense/layout.ts +0 -934
  132. package/publications/threat_intelligence/blog-defense/page-background.tsx +0 -590
  133. package/publications/threat_intelligence/brand.ts +0 -40
  134. package/publications/threat_intelligence/pictograms/art.tsx +0 -608
  135. package/publications/threat_intelligence/pictograms/figure.tsx +0 -82
  136. package/publications/threat_intelligence/pictograms/registry.ts +0 -30
  137. package/publications/threat_intelligence/pictograms/system.tsx +0 -305
  138. package/publications/threat_intelligence/seed/seed-edition-content.json +0 -629
  139. package/publications/threat_intelligence/theme.css +0 -1440
  140. /package/{publications/papyrus/theme.css → app/papyrus-theme.css} +0 -0
@@ -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.2",
2
+ "version": "1.0.0-next.21",
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,7 @@
158
190
  },
159
191
  {
160
192
  "file": "app/page.tsx",
193
+ "reader": true,
161
194
  "hasDefault": true,
162
195
  "exports": [],
163
196
  "config": {
@@ -166,6 +199,7 @@
166
199
  },
167
200
  {
168
201
  "file": "app/settings/page.tsx",
202
+ "reader": true,
169
203
  "hasDefault": true,
170
204
  "exports": [
171
205
  "metadata"
@@ -177,6 +211,7 @@
177
211
  ],
178
212
  "middleware": {
179
213
  "file": "middleware.ts",
180
- "matcher": "[\"/((?!_next/static|_next/image|favicon.ico|icon).*)\"]"
214
+ "matcher": "[\"/((?!_next/static|_next/image|favicon.ico|icon).*)\"]",
215
+ "runtime": "nodejs"
181
216
  }
182
217
  }
@@ -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
+ }