@anthusai/papyrus 0.0.0 → 1.0.0-next.10

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 (282) hide show
  1. package/LICENSE +21 -0
  2. package/amplify/auth/resource.js +61 -0
  3. package/amplify/auth/resource.ts +65 -0
  4. package/amplify/data/resource.js +2069 -0
  5. package/amplify/data/resource.ts +2185 -0
  6. package/amplify/data/schema.js +1882 -0
  7. package/amplify/data/schema.ts +1990 -0
  8. package/amplify/functions/assignment-action/handler.ts +1416 -0
  9. package/amplify/functions/assignment-action/resource.js +8 -0
  10. package/amplify/functions/assignment-action/resource.ts +9 -0
  11. package/amplify/functions/category-action/handler.ts +2259 -0
  12. package/amplify/functions/category-action/resource.js +8 -0
  13. package/amplify/functions/category-action/resource.ts +9 -0
  14. package/amplify/functions/console-chat-responder/Cargo.lock +3456 -0
  15. package/amplify/functions/console-chat-responder/Cargo.toml +26 -0
  16. package/amplify/functions/console-chat-responder/Dockerfile +19 -0
  17. package/amplify/functions/console-chat-responder/py/execute_tactus_runner.py +116 -0
  18. package/amplify/functions/console-chat-responder/resource.js +153 -0
  19. package/amplify/functions/console-chat-responder/resource.ts +179 -0
  20. package/amplify/functions/console-chat-responder/src/main.rs +3242 -0
  21. package/amplify/functions/content-actions/handler.py +149 -0
  22. package/amplify/functions/content-actions/requirements.txt +4 -0
  23. package/amplify/functions/content-actions/resource.js +40 -0
  24. package/amplify/functions/content-actions/resource.ts +54 -0
  25. package/amplify/functions/email-submission-processor/handler.py +46 -0
  26. package/amplify/functions/email-submission-processor/requirements.txt +2 -0
  27. package/amplify/functions/email-submission-processor/resource.js +56 -0
  28. package/amplify/functions/email-submission-processor/resource.ts +69 -0
  29. package/amplify/functions/graphql-jwt-authorizer/handler.ts +193 -0
  30. package/amplify/functions/graphql-jwt-authorizer/resource.js +14 -0
  31. package/amplify/functions/graphql-jwt-authorizer/resource.ts +15 -0
  32. package/amplify/functions/knowledge-query/handler.py +1 -0
  33. package/amplify/functions/knowledge-query/requirements.txt +3 -0
  34. package/amplify/functions/knowledge-query/resource.js +50 -0
  35. package/amplify/functions/knowledge-query/resource.ts +61 -0
  36. package/amplify/functions/manage-user-role/handler.ts +799 -0
  37. package/amplify/functions/manage-user-role/resource.js +8 -0
  38. package/amplify/functions/manage-user-role/resource.ts +9 -0
  39. package/amplify/functions/model-attachment-upload/handler.ts +387 -0
  40. package/amplify/functions/model-attachment-upload/resource.js +8 -0
  41. package/amplify/functions/model-attachment-upload/resource.ts +9 -0
  42. package/amplify/functions/newsroom-summary/handler.ts +321 -0
  43. package/amplify/functions/newsroom-summary/resource.js +8 -0
  44. package/amplify/functions/newsroom-summary/resource.ts +9 -0
  45. package/amplify/functions/procedure-action/handler.ts +621 -0
  46. package/amplify/functions/procedure-action/resource.js +8 -0
  47. package/amplify/functions/procedure-action/resource.ts +9 -0
  48. package/amplify/functions/reader-settings/handler.ts +322 -0
  49. package/amplify/functions/reader-settings/resource.js +8 -0
  50. package/amplify/functions/reader-settings/resource.ts +9 -0
  51. package/amplify/functions/ses-inbound-receive/handler.ts +493 -0
  52. package/amplify/functions/ses-inbound-receive/resource.js +8 -0
  53. package/amplify/functions/ses-inbound-receive/resource.ts +9 -0
  54. package/amplify/functions/shared/amplify-secrets.ts +65 -0
  55. package/amplify/functions/shared/console-chat-enqueue.ts +117 -0
  56. package/amplify/functions/shared/email-mime-intake.ts +196 -0
  57. package/amplify/functions/shared/email-submission.ts +415 -0
  58. package/amplify/functions/shared/inbound-email-intake.ts +37 -0
  59. package/amplify/functions/shared/lambda-data-client.ts +21 -0
  60. package/amplify/functions/shared/model-payloads.ts +186 -0
  61. package/amplify/functions/shared/python-bundle.js +130 -0
  62. package/amplify/functions/shared/python-bundle.ts +162 -0
  63. package/amplify/functions/shared/slack-events.ts +111 -0
  64. package/amplify/functions/slack-delivery/handler.py +112 -0
  65. package/amplify/functions/slack-delivery/requirements.txt +2 -0
  66. package/amplify/functions/slack-delivery/resource.js +38 -0
  67. package/amplify/functions/slack-delivery/resource.ts +51 -0
  68. package/amplify/functions/slack-events/handler.ts +155 -0
  69. package/amplify/functions/slack-events/resource.js +11 -0
  70. package/amplify/functions/slack-events/resource.ts +12 -0
  71. package/amplify/inbound-email/stack.js +46 -0
  72. package/amplify/inbound-email/stack.ts +61 -0
  73. package/amplify/package.json +3 -0
  74. package/amplify/rebuild-trigger-settings.js +22 -0
  75. package/amplify/rebuild-trigger-settings.ts +42 -0
  76. package/amplify/site-backend-config.js +1 -0
  77. package/amplify/site-backend-config.ts +36 -0
  78. package/amplify/site-backend.js +615 -0
  79. package/amplify/site-backend.ts +739 -0
  80. package/amplify/storage/resource.js +21 -0
  81. package/amplify/storage/resource.ts +22 -0
  82. package/app/%5F_preview/[[...path]]/route.ts +8 -0
  83. package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +62 -0
  84. package/app/[year]/[month]/[day]/edition-route-page.tsx +53 -0
  85. package/app/[year]/[month]/[day]/page/[pageNumber]/page.tsx +32 -0
  86. package/app/[year]/[month]/[day]/page.tsx +22 -0
  87. package/app/[year]/[month]/[day]/section/[sectionKey]/page.tsx +32 -0
  88. package/app/api/archive/editions/route.ts +18 -0
  89. package/app/api/media/[...path]/route.ts +35 -0
  90. package/app/api/revalidate/route.ts +94 -0
  91. package/app/archive/page.tsx +24 -0
  92. package/app/articles/[slug]/page.tsx +35 -0
  93. package/app/globals.css +10345 -0
  94. package/app/layout.tsx +142 -0
  95. package/app/newsroom/[section]/[...selection]/page.tsx +50 -0
  96. package/app/newsroom/[section]/page.tsx +47 -0
  97. package/app/newsroom/articles/[id]/page.tsx +15 -0
  98. package/app/newsroom/articles/page.tsx +13 -0
  99. package/app/newsroom/forum/[threadId]/page.tsx +35 -0
  100. package/app/newsroom/insights/[threadId]/page.tsx +22 -0
  101. package/app/newsroom/insights/page.tsx +14 -0
  102. package/app/newsroom/layout.tsx +12 -0
  103. package/app/newsroom/page.tsx +31 -0
  104. package/app/newsroom/sections/[sectionId]/page.tsx +17 -0
  105. package/app/page.tsx +272 -0
  106. package/app/papyrus-theme.css +11 -0
  107. package/app/robots.ts +11 -0
  108. package/app/settings/page.tsx +14 -0
  109. package/app/tailwind.css +65 -0
  110. package/bin/papyrus-app.mjs +20 -0
  111. package/bin/papyrus-infra.mjs +95 -0
  112. package/components/ai-elements/conversation.tsx +168 -0
  113. package/components/ai-elements/message.tsx +360 -0
  114. package/components/ai-elements/model-selector.tsx +30 -0
  115. package/components/ai-elements/prompt-input.tsx +1461 -0
  116. package/components/ai-elements/shimmer.tsx +76 -0
  117. package/components/ai-elements/suggestion.tsx +54 -0
  118. package/components/ai-elements/tool.tsx +173 -0
  119. package/components/amplify-client-provider.tsx +140 -0
  120. package/components/archive-grid.tsx +89 -0
  121. package/components/archive-shell.tsx +41 -0
  122. package/components/article-video.tsx +156 -0
  123. package/components/blog-page-background.tsx +20 -0
  124. package/components/dev-sandbox-editor-sign-in.tsx +60 -0
  125. package/components/markus-ir-preview.tsx +300 -0
  126. package/components/news-desk-client-provider.tsx +211 -0
  127. package/components/news-desk-page.tsx +116 -0
  128. package/components/news-desk-taxonomy-client.ts +3684 -0
  129. package/components/newspaper.tsx +1929 -0
  130. package/components/newsroom-access-gate.tsx +66 -0
  131. package/components/newsroom-article-editor.tsx +741 -0
  132. package/components/newsroom-articles-view.tsx +129 -0
  133. package/components/newsroom-assignments-view.tsx +664 -0
  134. package/components/newsroom-ops-overview.tsx +226 -0
  135. package/components/newsroom-ops-shell.tsx +321 -0
  136. package/components/newsroom-references-view.tsx +598 -0
  137. package/components/newsroom-topics-view.tsx +425 -0
  138. package/components/papyrus-console-shell.tsx +1912 -0
  139. package/components/pictogram-figure.tsx +65 -0
  140. package/components/presentation-footer.tsx +146 -0
  141. package/components/reader-auth-control.tsx +162 -0
  142. package/components/reader-auth-state.ts +122 -0
  143. package/components/reader-settings.ts +332 -0
  144. package/components/reader-theme-control.tsx +85 -0
  145. package/components/reference-source-preview.tsx +163 -0
  146. package/components/settings-page.tsx +239 -0
  147. package/components/staging-banner.tsx +11 -0
  148. package/components/topic-steering-workspace.tsx +18005 -0
  149. package/components/ui/badge.tsx +30 -0
  150. package/components/ui/button-group.tsx +87 -0
  151. package/components/ui/button.tsx +58 -0
  152. package/components/ui/card.tsx +32 -0
  153. package/components/ui/command.tsx +194 -0
  154. package/components/ui/dialog.tsx +158 -0
  155. package/components/ui/dropdown-menu.tsx +266 -0
  156. package/components/ui/hover-card.tsx +51 -0
  157. package/components/ui/input-group.tsx +158 -0
  158. package/components/ui/input.tsx +20 -0
  159. package/components/ui/scroll-area.tsx +16 -0
  160. package/components/ui/select.tsx +199 -0
  161. package/components/ui/separator.tsx +23 -0
  162. package/components/ui/sheet.tsx +92 -0
  163. package/components/ui/spinner.tsx +10 -0
  164. package/components/ui/tabs.tsx +113 -0
  165. package/components/ui/textarea.tsx +18 -0
  166. package/components/ui/tooltip.tsx +66 -0
  167. package/components/use-resolved-papyrus-theme.ts +47 -0
  168. package/infra/amplify-app-shell.js +234 -0
  169. package/infra/amplify-app-shell.ts +284 -0
  170. package/infra/build-specs.js +85 -0
  171. package/infra/build-specs.ts +131 -0
  172. package/infra/github-oidc-provider.js +24 -0
  173. package/infra/github-oidc-provider.ts +30 -0
  174. package/infra/index.js +15 -0
  175. package/infra/site-config.js +186 -0
  176. package/infra/site-config.ts +235 -0
  177. package/lib/amplify-outputs-guard.ts +24 -0
  178. package/lib/amplify-outputs-path.ts +16 -0
  179. package/lib/amplify-server-runtime.ts +63 -0
  180. package/lib/archive-data.ts +30 -0
  181. package/lib/archive-types.ts +15 -0
  182. package/lib/articles.ts +320 -0
  183. package/lib/cached-content-repository.ts +90 -0
  184. package/lib/category-dashboard.ts +37 -0
  185. package/lib/category-repository.ts +1907 -0
  186. package/lib/console-chat-client.ts +1316 -0
  187. package/lib/console-web-context.ts +118 -0
  188. package/lib/content-actions-client.ts +98 -0
  189. package/lib/content-actions-demo.ts +190 -0
  190. package/lib/content-load-timing.ts +23 -0
  191. package/lib/content-repository.ts +43 -0
  192. package/lib/content-source-context.ts +26 -0
  193. package/lib/content-types.ts +104 -0
  194. package/lib/define-site.js +3 -0
  195. package/lib/define-site.ts +21 -0
  196. package/lib/demo-amplify-outputs.ts +15 -0
  197. package/lib/dev-sandbox-editor-auth.ts +18 -0
  198. package/lib/doctrine.ts +128 -0
  199. package/lib/edition-routes.ts +155 -0
  200. package/lib/edition-sections.ts +111 -0
  201. package/lib/empty-edition-layout-plan.ts +109 -0
  202. package/lib/empty-theme.css +1 -0
  203. package/lib/excerpts.ts +8 -0
  204. package/lib/graphql-content-repository.ts +1140 -0
  205. package/lib/image-url.ts +10 -0
  206. package/lib/layout-plan.ts +981 -0
  207. package/lib/layout-scenarios.ts +564 -0
  208. package/lib/markus-body.ts +168 -0
  209. package/lib/markus-ir.ts +235 -0
  210. package/lib/markus-projection.ts +243 -0
  211. package/lib/markus-to-article.ts +93 -0
  212. package/lib/news-desk-session.ts +160 -0
  213. package/lib/newspaper-layout.ts +3459 -0
  214. package/lib/newsroom-articles.ts +325 -0
  215. package/lib/newsroom-assignments.ts +173 -0
  216. package/lib/newsroom-base-path.ts +48 -0
  217. package/lib/newsroom-card-layout.ts +102 -0
  218. package/lib/newsroom-category-drilldown.ts +133 -0
  219. package/lib/newsroom-demo-articles.ts +90 -0
  220. package/lib/newsroom-demo-dashboard.ts +267 -0
  221. package/lib/newsroom-demo-profile.ts +46 -0
  222. package/lib/newsroom-forum-routes.ts +153 -0
  223. package/lib/newsroom-index-filters.ts +202 -0
  224. package/lib/newsroom-knowledge-query-request.ts +51 -0
  225. package/lib/newsroom-list-selection.ts +42 -0
  226. package/lib/newsroom-media-backend.ts +142 -0
  227. package/lib/newsroom-media.ts +182 -0
  228. package/lib/newsroom-nav.ts +44 -0
  229. package/lib/newsroom-references.ts +116 -0
  230. package/lib/newsroom-sections.ts +52 -0
  231. package/lib/newsroom-topics.ts +118 -0
  232. package/lib/papyrus-object-kinds.ts +24 -0
  233. package/lib/papyrus-web-locations.ts +480 -0
  234. package/lib/pilobol-sample.ts +167 -0
  235. package/lib/presentation-footer.ts +92 -0
  236. package/lib/pretext-layout.ts +179 -0
  237. package/lib/public-placeholder-config.ts +102 -0
  238. package/lib/publication-items.ts +181 -0
  239. package/lib/reader-route-config.ts +1 -0
  240. package/lib/reader-static-params.ts +56 -0
  241. package/lib/reader-storage-url.ts +63 -0
  242. package/lib/reference-display.ts +74 -0
  243. package/lib/reference-policy.ts +97 -0
  244. package/lib/reference-source-preview.ts +175 -0
  245. package/lib/renderer-config.ts +50 -0
  246. package/lib/renderer.ts +87 -0
  247. package/lib/reporting-story-budget.ts +638 -0
  248. package/lib/semantic-graph.ts +554 -0
  249. package/lib/site-brand-tenant.ts +22 -0
  250. package/lib/site-brand.ts +194 -0
  251. package/lib/site-env.ts +52 -0
  252. package/lib/site-renderer.ts +17 -0
  253. package/lib/site-stack.ts +195 -0
  254. package/lib/staging-gate.ts +37 -0
  255. package/lib/staging-preview-object.ts +98 -0
  256. package/lib/themed-image.ts +30 -0
  257. package/lib/utils.ts +6 -0
  258. package/lib/video-mode.ts +42 -0
  259. package/lib/video-script.ts +63 -0
  260. package/middleware.ts +103 -0
  261. package/package.json +318 -1
  262. package/renderers/markus/index.ts +25 -0
  263. package/renderers/markus/stubs.tsx +33 -0
  264. package/renderers/pretext/article-page.tsx +169 -0
  265. package/renderers/pretext/index.ts +30 -0
  266. package/renderers/pretext/layouts.ts +16 -0
  267. package/renderers/pretext/presentation-shell.tsx +470 -0
  268. package/routes.manifest.json +208 -0
  269. package/scripts/fixtures/pilobol-sample.ast.json +234 -0
  270. package/scripts/videoml/build-browser-bundle.mjs +22 -0
  271. package/scripts/videoml/build-preview-bundle.mjs +50 -0
  272. package/scripts/videoml/bundle-common.mjs +129 -0
  273. package/scripts/videoml/preview/preview-entry.tsx +381 -0
  274. package/scripts/videoml/preview/preview-stage-fit.ts +28 -0
  275. package/scripts/videoml/preview/preview-timing.ts +42 -0
  276. package/scripts/videoml/preview.html +77 -0
  277. package/scripts/videoml/standard-entry.tsx +46 -0
  278. package/src/backend.js +1 -0
  279. package/src/backend.ts +3 -0
  280. package/src/sync.mjs +87 -0
  281. package/src/with-papyrus.d.mts +2 -0
  282. package/src/with-papyrus.mjs +60 -0
@@ -0,0 +1,169 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import type { Article } from "../../lib/articles";
5
+ import type { PublicationItem } from "../../lib/publication-items";
6
+ import { getPublicationItemVideoAsset } from "../../lib/publication-items";
7
+ import type { ArticlePageEditionFooter, RenderArticleProps, RenderItemProps } from "../../lib/renderer";
8
+ import { SITE_BRAND } from "../../lib/site-brand";
9
+ import { ArticleVideoFigure } from "../../components/article-video";
10
+ import { PictogramFigure as GenericPictogramFigure } from "../../components/pictogram-figure";
11
+ import { PresentationFooter } from "../../components/presentation-footer";
12
+
13
+ const PictogramFigure = SITE_BRAND.components?.PictogramFigure ?? GenericPictogramFigure;
14
+
15
+ export type { ArticlePageEditionFooter };
16
+
17
+ type ArticlePageViewProps = RenderArticleProps;
18
+
19
+ export function ArticlePageView({ article, backHref, backLabel = SITE_BRAND.backToHomeLabel, editionFooter, editionDate }: ArticlePageViewProps) {
20
+ const articleDate = editionDate ? formatArticleDate(editionDate) : null;
21
+
22
+ return (
23
+ <main className={getArticleShellClassName(editionFooter)}>
24
+ <nav className="article-nav">
25
+ <Link href={backHref}>{backLabel}</Link>
26
+ <span>{article.section}</span>
27
+ </nav>
28
+ <article className="article-page">
29
+ {article.video ? (
30
+ <div className="article-page__hero-video">
31
+ <ArticleVideoFigure slug={article.slug} video={article.video} />
32
+ </div>
33
+ ) : null}
34
+ <header>
35
+ <p className="story-label">{article.section}</p>
36
+ <h1>{article.headline}</h1>
37
+ <p className="article-deck">{article.deck}</p>
38
+ <div className="story-byline">
39
+ <span>{article.byline}</span>
40
+ <span>{article.dateline}</span>
41
+ {articleDate ? <time dateTime={editionDate}>{articleDate}</time> : null}
42
+ </div>
43
+ </header>
44
+ <div className="article-body">
45
+ <ArticleLeadPictogram article={article} />
46
+ {article.body.map((paragraph) => (
47
+ <p key={paragraph}>{paragraph}</p>
48
+ ))}
49
+ </div>
50
+ </article>
51
+ {editionFooter ? <ArticleEditionFooter footer={editionFooter} /> : null}
52
+ </main>
53
+ );
54
+ }
55
+
56
+ type ItemPageViewProps = RenderItemProps;
57
+
58
+ export function ItemPageView({ item, backHref, backLabel = "Back to edition", editionFooter, editionDate }: ItemPageViewProps) {
59
+ if (item.type === "article") {
60
+ return <ArticlePageView article={item} backHref={backHref} backLabel={backLabel} editionFooter={editionFooter} editionDate={editionDate} />;
61
+ }
62
+
63
+ const itemDate = editionDate ? formatArticleDate(editionDate) : null;
64
+ const itemVideo = getPublicationItemVideoAsset(item);
65
+
66
+ return (
67
+ <main className={getArticleShellClassName(editionFooter)}>
68
+ <nav className="article-nav">
69
+ <Link href={backHref}>{backLabel}</Link>
70
+ <span>{item.section ?? item.type}</span>
71
+ </nav>
72
+ <article className="article-page" data-item-type={item.type}>
73
+ {itemVideo ? (
74
+ <div className="article-page__hero-video">
75
+ <ArticleVideoFigure slug={item.slug} video={itemVideo} />
76
+ </div>
77
+ ) : null}
78
+ <header>
79
+ <p className="story-label">{item.section ?? item.type}</p>
80
+ <h1>{item.title}</h1>
81
+ {item.deck ? <p className="article-deck">{item.deck}</p> : null}
82
+ {itemDate ? (
83
+ <div className="story-byline">
84
+ <time dateTime={editionDate}>{itemDate}</time>
85
+ </div>
86
+ ) : null}
87
+ </header>
88
+ <div className="article-body">
89
+ <ItemLeadPictogram item={item} />
90
+ {(item.body ?? []).map((paragraph) => (
91
+ <p key={paragraph}>{paragraph}</p>
92
+ ))}
93
+ </div>
94
+ </article>
95
+ {editionFooter ? <ArticleEditionFooter footer={editionFooter} /> : null}
96
+ </main>
97
+ );
98
+ }
99
+
100
+ function ArticleLeadPictogram({ article }: { article: Article }) {
101
+ if (!article.image) return null;
102
+ return (
103
+ <PictogramFigure
104
+ alt={article.image.alt}
105
+ caption={article.image.caption}
106
+ credit={article.image.credit}
107
+ figureClassName="article-photo"
108
+ height={680}
109
+ layout={article.image.layout}
110
+ priority
111
+ sizes="(max-width: 980px) 100vw, 900px"
112
+ slug={article.slug}
113
+ src={article.image.src}
114
+ themeVariants={article.image.themeVariants}
115
+ width={1200}
116
+ />
117
+ );
118
+ }
119
+
120
+ function ItemLeadPictogram({ item }: { item: PublicationItem }) {
121
+ const image = item.type === "article" ? item.image : item.image;
122
+ if (!image) return null;
123
+ return (
124
+ <PictogramFigure
125
+ alt={image.alt}
126
+ caption={image.caption}
127
+ credit={image.credit}
128
+ figureClassName="article-photo"
129
+ height={680}
130
+ layout={image.layout}
131
+ priority
132
+ sizes="(max-width: 980px) 100vw, 900px"
133
+ slug={item.slug}
134
+ src={image.src}
135
+ themeVariants={image.themeVariants}
136
+ width={1200}
137
+ />
138
+ );
139
+ }
140
+
141
+ function ArticleEditionFooter({ footer }: { footer: ArticlePageEditionFooter }) {
142
+ return (
143
+ <PresentationFooter
144
+ editionBasePath={footer.editionBasePath}
145
+ entries={footer.entries}
146
+ resolveSectionHref={(entry) => `${footer.editionBasePath}#section-${entry.sectionKey}`}
147
+ subtitle={footer.subtitle}
148
+ title={footer.title}
149
+ />
150
+ );
151
+ }
152
+
153
+ function getArticleShellClassName(editionFooter: ArticlePageEditionFooter | undefined): string {
154
+ return editionFooter ? "article-shell article-shell--edition" : "article-shell";
155
+ }
156
+
157
+ function formatArticleDate(value: string): string {
158
+ const normalized = value.trim();
159
+ if (!normalized) return value;
160
+
161
+ const date = new Date(`${normalized}T00:00:00`);
162
+ if (Number.isNaN(date.getTime())) return value;
163
+
164
+ return new Intl.DateTimeFormat("en-US", {
165
+ day: "numeric",
166
+ month: "long",
167
+ year: "numeric",
168
+ }).format(date);
169
+ }
@@ -0,0 +1,30 @@
1
+ import type { Renderer } from "../../lib/renderer";
2
+ import { PRETEXT_LAYOUTS, pretextSupportsLayout } from "./layouts";
3
+ import { ArticlePageView, ItemPageView } from "./article-page";
4
+ import { PresentationShell } from "./presentation-shell";
5
+
6
+ export { ArticlePageView, ItemPageView } from "./article-page";
7
+ export { PresentationShell } from "./presentation-shell";
8
+ export type { PresentationTarget } from "./presentation-shell";
9
+ export { PRETEXT_LAYOUTS, pretextSupportsLayout } from "./layouts";
10
+
11
+ /**
12
+ * The Pretext renderer: the existing client-side layout solver
13
+ * (`@chenglou/pretext` + `components/newspaper.tsx` +
14
+ * `renderers/pretext/presentation-shell.tsx`), retrofitted onto the
15
+ * `Renderer` interface (lib/renderer.ts). This is a pure retrofit -- no
16
+ * rendering behavior changed, only how the existing implementation is
17
+ * exposed.
18
+ */
19
+ export const pretextRenderer: Renderer = {
20
+ id: "pretext",
21
+ renderEdition: PresentationShell,
22
+ renderArticle: ArticlePageView,
23
+ renderItem: ItemPageView,
24
+ // Pretext's CSS is not extracted from the monolithic app/globals.css
25
+ // bundle imported unconditionally by app/layout.tsx, so there is nothing
26
+ // additional for this renderer to declare today. See lib/renderer.ts's
27
+ // `stylesheets` doc comment.
28
+ stylesheets: () => [],
29
+ supportsLayout: pretextSupportsLayout,
30
+ };
@@ -0,0 +1,16 @@
1
+ import type { PretextLayout } from "../../lib/renderer-config";
2
+
3
+ /**
4
+ * Layout ("newsprint" | "blog" | "magazine") is Pretext-internal: it is the
5
+ * look within Pretext, not a concept every renderer needs to share. This is
6
+ * the canonical list of layouts the Pretext renderer actually supports,
7
+ * consumed both by `renderers/pretext`'s `supportsLayout` and by
8
+ * `lib/site-brand.ts`'s `getPresentationChoices` (today the only renderer,
9
+ * so "layouts the app can choose from" and "layouts Pretext supports" are
10
+ * the same list).
11
+ */
12
+ export const PRETEXT_LAYOUTS: readonly PretextLayout[] = ["newsprint", "blog", "magazine"];
13
+
14
+ export function pretextSupportsLayout(layout: PretextLayout): boolean {
15
+ return PRETEXT_LAYOUTS.includes(layout);
16
+ }
@@ -0,0 +1,470 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import type { CSSProperties, MouseEvent as ReactMouseEvent, ReactNode, RefObject } from "react";
5
+ import { useEffect, useMemo, useRef, useState } from "react";
6
+ import { findEditionSection, getEditionSectionItems } from "../../lib/edition-sections";
7
+ import { getEditionSectionPath } from "../../lib/edition-routes";
8
+ import { buildPresentationFooterEntries, type PresentationFooterEntry } from "../../lib/presentation-footer";
9
+ import { layoutAllTextLines, prepareWithSegments, type TextLine } from "../../lib/pretext-layout";
10
+ import { truncateWords } from "../../lib/excerpts";
11
+ import {
12
+ getPublicationItemImageAssets,
13
+ getPublicationItemVideoAsset,
14
+ type PublicationItem,
15
+ } from "../../lib/publication-items";
16
+ import type { EditionContent, EditionPresentationFormat, EditionSection } from "../../lib/content-types";
17
+ import type { ArticleVideoAsset } from "../../lib/articles";
18
+ import type { PresentationTarget, RenderEditionProps } from "../../lib/renderer";
19
+ import { SITE_BRAND, enforcePresentation, getDefaultPretextLayout } from "../../lib/site-brand";
20
+ import { ArticleVideoFigure } from "../../components/article-video";
21
+ import { BlogPageBackground as GenericBlogPageBackground } from "../../components/blog-page-background";
22
+ import { Newspaper } from "../../components/newspaper";
23
+ import { PictogramFigure as GenericPictogramFigure } from "../../components/pictogram-figure";
24
+ import { PresentationFooter } from "../../components/presentation-footer";
25
+ import { readLocalReaderSettings, resolveReaderSettings, subscribeReaderSettingsChanges } from "../../components/reader-settings";
26
+
27
+ const BlogPageBackground = SITE_BRAND.components?.BlogPageBackground ?? GenericBlogPageBackground;
28
+ const PictogramFigure = SITE_BRAND.components?.PictogramFigure ?? GenericPictogramFigure;
29
+
30
+ export type { PresentationTarget };
31
+
32
+ type PresentationShellProps = RenderEditionProps;
33
+
34
+ const PRESENTATION_TEXT_FONT = SITE_BRAND.textFont;
35
+ const BLOG_TEXT_STYLE = {
36
+ fontSize: 18,
37
+ lineHeight: 28,
38
+ linePaintHeight: 24,
39
+ fontFamily: PRESENTATION_TEXT_FONT,
40
+ };
41
+ const MAGAZINE_TEXT_STYLE = {
42
+ fontSize: 17,
43
+ lineHeight: 25,
44
+ linePaintHeight: 22,
45
+ fontFamily: PRESENTATION_TEXT_FONT,
46
+ };
47
+
48
+ export function PresentationShell({
49
+ content,
50
+ editionBasePath,
51
+ mastheadHomeHref,
52
+ initialPageNumber = 1,
53
+ lockedPresentation,
54
+ target = { kind: "edition" },
55
+ }: PresentationShellProps) {
56
+ const defaultPresentation = enforcePresentation(getDefaultPretextLayout());
57
+ const [preferredPresentation, setPreferredPresentation] = useState<EditionPresentationFormat>(defaultPresentation);
58
+ const activePresentation = enforcePresentation(lockedPresentation ?? preferredPresentation);
59
+ const targetSection = target.kind === "section" ? findEditionSection(content.sections, target.sectionKey) : undefined;
60
+
61
+ useEffect(() => {
62
+ if (lockedPresentation) return;
63
+ const localSettings = readLocalReaderSettings();
64
+ setPreferredPresentation(enforcePresentation(localSettings.presentation));
65
+ const unsubscribe = subscribeReaderSettingsChanges((settings) => {
66
+ setPreferredPresentation(enforcePresentation(settings.presentation));
67
+ });
68
+ void resolveReaderSettings()
69
+ .then((resolution) => setPreferredPresentation(enforcePresentation(resolution.settings.presentation)))
70
+ .catch(() => undefined);
71
+ return unsubscribe;
72
+ }, [lockedPresentation]);
73
+
74
+ if (activePresentation === "newsprint") {
75
+ return (
76
+ <PresentationFrame>
77
+ <Newspaper
78
+ content={content}
79
+ editionBasePath={editionBasePath}
80
+ mastheadHomeHref={mastheadHomeHref}
81
+ initialPageNumber={initialPageNumber}
82
+ initialSectionKey={targetSection?.key}
83
+ preserveContentLocation={Boolean(targetSection)}
84
+ />
85
+ </PresentationFrame>
86
+ );
87
+ }
88
+
89
+ const presentationProps = {
90
+ content,
91
+ editionBasePath,
92
+ targetSection,
93
+ };
94
+
95
+ return (
96
+ <PresentationFrame>
97
+ {activePresentation === "blog" ? (
98
+ <BlogPresentation {...presentationProps} />
99
+ ) : (
100
+ <MagazinePresentation {...presentationProps} />
101
+ )}
102
+ </PresentationFrame>
103
+ );
104
+ }
105
+
106
+ function PresentationFrame({ children }: { children: ReactNode }) {
107
+ return <>{children}</>;
108
+ }
109
+
110
+ function BlogPresentation({
111
+ content,
112
+ editionBasePath,
113
+ targetSection,
114
+ }: {
115
+ content: EditionContent;
116
+ editionBasePath?: string;
117
+ targetSection?: EditionSection;
118
+ }) {
119
+ const sections = content.sections;
120
+ const footerEntries = useMemo(() => buildPresentationFooterEntries(content), [content]);
121
+ const footerSubtitle = SITE_BRAND.id === "papyrus" ? (content.description?.trim() || "Inside Papyrus") : SITE_BRAND.mastheadSubtitle;
122
+ const contentRef = useRef<HTMLDivElement | null>(null);
123
+ const pageRef = useRef<HTMLElement | null>(null);
124
+ usePresentationTargetScroll(targetSection);
125
+ return (
126
+ <main className="presentation-page presentation-page--blog" data-presentation-engine="blog" ref={pageRef}>
127
+ <BlogPageBackground pageRef={pageRef} />
128
+ <PresentationHeader content={content} />
129
+ <SectionNavigation content={content} editionBasePath={editionBasePath} />
130
+ <div className="blog-sections" ref={contentRef}>
131
+ {sections.map((section, sectionIndex) => (
132
+ <section className="blog-section" data-edition-section={section.key} id={getSectionAnchorId(section.key)} key={section.key}>
133
+ {sectionIndex === 0 && content.editionVideo ? (
134
+ <EditionOverviewVideo editionVideo={content.editionVideo} />
135
+ ) : null}
136
+ <header className="presentation-section-header">
137
+ <p>{section.label}</p>
138
+ {section.description ? <span>{section.description}</span> : null}
139
+ </header>
140
+ {getEditionSectionItems(section, content.items).map((item, index) => (
141
+ <PresentationItem
142
+ editionBasePath={editionBasePath}
143
+ index={index}
144
+ item={item}
145
+ key={item.slug}
146
+ mode="blog"
147
+ />
148
+ ))}
149
+ </section>
150
+ ))}
151
+ </div>
152
+ <PresentationFooter
153
+ editionBasePath={editionBasePath}
154
+ entries={footerEntries}
155
+ onSectionClick={handleBlogFooterSectionClick}
156
+ resolveSectionHref={(entry) => getBlogFooterSectionHref(entry, editionBasePath)}
157
+ subtitle={SITE_BRAND.footerSubtitleOverride ?? footerSubtitle}
158
+ title={SITE_BRAND.footerTitle}
159
+ />
160
+ </main>
161
+ );
162
+ }
163
+
164
+ function MagazinePresentation({
165
+ content,
166
+ editionBasePath,
167
+ targetSection,
168
+ }: {
169
+ content: EditionContent;
170
+ editionBasePath?: string;
171
+ targetSection?: EditionSection;
172
+ }) {
173
+ const sections = targetSection ? [targetSection] : content.sections;
174
+ usePresentationTargetScroll(targetSection);
175
+ return (
176
+ <main className="presentation-page presentation-page--magazine" data-presentation-engine="magazine">
177
+ <PresentationHeader content={content} />
178
+ <SectionNavigation content={content} editionBasePath={editionBasePath} />
179
+ <div className="magazine-sections">
180
+ {sections.map((section) => {
181
+ const items = getEditionSectionItems(section, content.items);
182
+ return (
183
+ <section className="magazine-section" data-edition-section={section.key} id={getSectionAnchorId(section.key)} key={section.key}>
184
+ <header className="presentation-section-header presentation-section-header--magazine">
185
+ <p>{section.label}</p>
186
+ {section.description ? <span>{section.description}</span> : null}
187
+ </header>
188
+ <div className="magazine-spread">
189
+ {items.map((item, index) => (
190
+ <PresentationItem
191
+ editionBasePath={editionBasePath}
192
+ item={item}
193
+ key={item.slug}
194
+ mode={index === 0 ? "magazine-feature" : "magazine"}
195
+ />
196
+ ))}
197
+ </div>
198
+ </section>
199
+ );
200
+ })}
201
+ </div>
202
+ </main>
203
+ );
204
+ }
205
+
206
+ function EditionOverviewVideo({ editionVideo }: { editionVideo: ArticleVideoAsset }) {
207
+ return (
208
+ <section className="presentation-edition-video" aria-label={editionVideo.alt}>
209
+ <ArticleVideoFigure
210
+ figureClassName="presentation-edition-video__figure article-video"
211
+ slug="edition-overview"
212
+ video={editionVideo}
213
+ />
214
+ </section>
215
+ );
216
+ }
217
+
218
+ function PresentationHeader({ content }: { content: EditionContent }) {
219
+ const title = SITE_BRAND.mastheadSource === "brand" ? SITE_BRAND.mastheadTitle : content.title;
220
+ const subtitle = SITE_BRAND.mastheadSource === "brand" ? SITE_BRAND.mastheadSubtitle : content.description;
221
+ const tagline = SITE_BRAND.mastheadTagline ?? null;
222
+ const displayDate = SITE_BRAND.mastheadDateFormat === "formatted"
223
+ ? formatMastheadDate(content.editionDate)
224
+ : content.editionDate;
225
+
226
+ return (
227
+ <header className="presentation-header">
228
+ <div className="presentation-header__copy-stack">
229
+ <h1>
230
+ {SITE_BRAND.mastheadWordSplit
231
+ ? title.split(/\s+/).map((word) => <span key={word}>{word}</span>)
232
+ : title}
233
+ </h1>
234
+ <div className="presentation-header__meta">
235
+ {subtitle ? <span className="presentation-header__subtitle">{subtitle}</span> : null}
236
+ <p className="presentation-header__date">{displayDate}</p>
237
+ </div>
238
+ {tagline ? <span className="presentation-header__tagline">{tagline}</span> : null}
239
+ </div>
240
+ </header>
241
+ );
242
+ }
243
+
244
+ function SectionNavigation({ content, editionBasePath }: { content: EditionContent; editionBasePath?: string }) {
245
+ return (
246
+ <nav className="presentation-section-nav" aria-label="Edition sections">
247
+ {content.sections.map((section) => (
248
+ <Link href={getSectionHref(content, section, editionBasePath)} key={section.key}>
249
+ {section.label}
250
+ </Link>
251
+ ))}
252
+ </nav>
253
+ );
254
+ }
255
+
256
+ function PresentationItem({
257
+ editionBasePath,
258
+ index,
259
+ item,
260
+ mode,
261
+ }: {
262
+ editionBasePath?: string;
263
+ index?: number;
264
+ item: PublicationItem;
265
+ mode: "blog" | "magazine" | "magazine-feature";
266
+ }) {
267
+ const frameRef = useRef<HTMLDivElement | null>(null);
268
+ const maxWidth = useMeasuredWidth(frameRef);
269
+ const image = getPublicationItemImageAssets(item)[0];
270
+ const video = getPublicationItemVideoAsset(item);
271
+ const textStyle = mode === "blog" ? BLOG_TEXT_STYLE : MAGAZINE_TEXT_STYLE;
272
+ const text = getPresentationBodyText(item, mode);
273
+ const itemRole = getPresentationItemRole(mode, index);
274
+ const lines = useMemo(() => {
275
+ if (!maxWidth || !text.trim()) return [];
276
+ return layoutAllTextLines({
277
+ prepared: prepareWithSegments(text, `${textStyle.fontSize}px ${textStyle.fontFamily}`),
278
+ maxWidth,
279
+ ...textStyle,
280
+ });
281
+ }, [maxWidth, text, textStyle]);
282
+ const textHeight = getMeasuredTextHeight(lines);
283
+ const directHref = editionBasePath ? `${editionBasePath}/${encodeURIComponent(item.slug)}` : `/articles/${encodeURIComponent(item.slug)}`;
284
+
285
+ return (
286
+ <article
287
+ className={`presentation-item presentation-item--${mode}`}
288
+ data-item-id={item.slug}
289
+ data-item-index={index}
290
+ data-item-role={itemRole}
291
+ data-item-type={item.type}
292
+ data-has-image={image ? "true" : "false"}
293
+ data-has-video={video ? "true" : "false"}
294
+ id={item.slug}
295
+ >
296
+ <div className="presentation-item__copy">
297
+ <header className="presentation-item__header">
298
+ <p>{item.section ?? "General"}</p>
299
+ <h2>
300
+ <Link href={directHref}>{getPresentationTitle(item)}</Link>
301
+ </h2>
302
+ {item.deck ? <span>{item.deck}</span> : null}
303
+ </header>
304
+ <div className="presentation-item__text-frame" ref={frameRef} style={{ height: textHeight }}>
305
+ <MeasuredPresentationLines lines={lines} />
306
+ </div>
307
+ {mode === "blog" ? (
308
+ <Link className="presentation-item__cta" href={directHref}>
309
+ Read Article
310
+ </Link>
311
+ ) : null}
312
+ </div>
313
+ {image ? (
314
+ <div className="presentation-item__media">
315
+ <PictogramFigure
316
+ alt={image.alt}
317
+ caption={image.caption}
318
+ credit={image.credit}
319
+ figureClassName="presentation-item__image"
320
+ height={760}
321
+ layout={image.layout}
322
+ sizes={mode === "blog" ? "(max-width: 900px) 100vw, 760px" : "(max-width: 900px) 100vw, 50vw"}
323
+ slug={item.slug}
324
+ src={image.src}
325
+ themeVariants={image.themeVariants}
326
+ width={1200}
327
+ />
328
+ </div>
329
+ ) : null}
330
+ {video && mode === "blog" ? (
331
+ <div className="presentation-item__video">
332
+ <ArticleVideoFigure
333
+ figureClassName="presentation-item__video-figure article-video"
334
+ slug={item.slug}
335
+ video={video}
336
+ />
337
+ </div>
338
+ ) : null}
339
+ </article>
340
+ );
341
+ }
342
+
343
+ function getPresentationItemRole(mode: "blog" | "magazine" | "magazine-feature", index?: number): "lead" | "secondary" {
344
+ if (mode === "magazine-feature") return "lead";
345
+ if (mode === "blog" && index === 0) return "lead";
346
+ return "secondary";
347
+ }
348
+
349
+ function MeasuredPresentationLines({ lines }: { lines: TextLine[] }) {
350
+ return (
351
+ <div className="presentation-measured-lines">
352
+ {lines.map((line, index) => (
353
+ <span
354
+ className="presentation-measured-line"
355
+ key={`${index}-${line.text}`}
356
+ style={{
357
+ "--line-font-family": line.fontFamily,
358
+ "--line-font-size": `${line.fontSize}px`,
359
+ "--line-height": `${line.lineHeight}px`,
360
+ "--line-paint-height": `${line.paintHeight}px`,
361
+ left: line.x,
362
+ top: line.y,
363
+ } as CSSProperties}
364
+ >
365
+ {line.text}
366
+ </span>
367
+ ))}
368
+ </div>
369
+ );
370
+ }
371
+
372
+ function useMeasuredWidth(ref: RefObject<HTMLElement | null>): number {
373
+ const [width, setWidth] = useState(0);
374
+ useEffect(() => {
375
+ const node = ref.current;
376
+ if (!node) return;
377
+ const update = () => setWidth(Math.max(1, Math.floor(node.getBoundingClientRect().width)));
378
+ update();
379
+ const observer = new ResizeObserver(update);
380
+ observer.observe(node);
381
+ window.addEventListener("resize", update);
382
+ return () => {
383
+ observer.disconnect();
384
+ window.removeEventListener("resize", update);
385
+ };
386
+ }, [ref]);
387
+ return width;
388
+ }
389
+
390
+ function usePresentationTargetScroll(targetSection: EditionSection | undefined) {
391
+ useEffect(() => {
392
+ const scrollToCurrentTarget = () => {
393
+ const hashTarget = parseItemAnchorHash(window.location.hash);
394
+ const targetId = hashTarget ?? (targetSection ? getSectionAnchorId(targetSection.key) : null);
395
+ if (!targetId) return;
396
+ document.getElementById(targetId)?.scrollIntoView({ block: "start" });
397
+ };
398
+ requestAnimationFrame(scrollToCurrentTarget);
399
+ window.addEventListener("hashchange", scrollToCurrentTarget);
400
+ return () => window.removeEventListener("hashchange", scrollToCurrentTarget);
401
+ }, [targetSection]);
402
+ }
403
+
404
+ function getSectionHref(content: EditionContent, section: EditionSection, editionBasePath?: string): string {
405
+ if (SITE_BRAND.sectionLinkStrategy === "anchor") {
406
+ const anchor = `#${getSectionAnchorId(section.key)}`;
407
+ return editionBasePath ? `${editionBasePath}${anchor}` : anchor;
408
+ }
409
+ if (editionBasePath) return `${editionBasePath}/section/${encodeURIComponent(section.key)}`;
410
+ const datedPath = getEditionSectionPath(content.editionDate, section.key);
411
+ return datedPath.startsWith("//") ? `#${getSectionAnchorId(section.key)}` : datedPath;
412
+ }
413
+
414
+ function getSectionAnchorId(sectionKey: string): string {
415
+ return `section-${sectionKey}`;
416
+ }
417
+
418
+ function getMeasuredTextHeight(lines: TextLine[]): number {
419
+ const last = lines[lines.length - 1];
420
+ return last ? last.y + last.paintHeight : 0;
421
+ }
422
+
423
+ function getPresentationTitle(item: PublicationItem): string {
424
+ return item.type === "article" ? item.headline : item.title;
425
+ }
426
+
427
+ function getPresentationBodyText(item: PublicationItem, mode: "blog" | "magazine" | "magazine-feature"): string {
428
+ const body = item.type === "article" ? item.body.join("\n\n") : (item.body ?? []).join("\n\n");
429
+ if (mode !== "blog") return body;
430
+ const excerpt = String(item.excerpt ?? "").trim();
431
+ if (excerpt) return excerpt;
432
+ return truncateWords(body, 80);
433
+ }
434
+
435
+ function parseItemAnchorHash(hash: string): string | null {
436
+ if (!hash) return null;
437
+ try {
438
+ const value = decodeURIComponent(hash.slice(1)).trim();
439
+ return /^[a-z0-9][a-z0-9-]*$/i.test(value) ? value : null;
440
+ } catch {
441
+ return null;
442
+ }
443
+ }
444
+
445
+ function formatMastheadDate(value: string): string {
446
+ const normalized = value?.trim();
447
+ if (!normalized) return value;
448
+ const date = new Date(`${normalized}T00:00:00`);
449
+ if (Number.isNaN(date.getTime())) return value;
450
+ return new Intl.DateTimeFormat("en-US", {
451
+ month: "long",
452
+ day: "numeric",
453
+ year: "numeric",
454
+ }).format(date);
455
+ }
456
+
457
+ function getBlogFooterSectionHref(entry: PresentationFooterEntry, editionBasePath?: string): string {
458
+ const anchor = `#${getSectionAnchorId(entry.sectionKey)}`;
459
+ return editionBasePath ? `${editionBasePath}${anchor}` : anchor;
460
+ }
461
+
462
+ function handleBlogFooterSectionClick(
463
+ event: ReactMouseEvent<HTMLAnchorElement>,
464
+ entry: PresentationFooterEntry,
465
+ href: string,
466
+ ) {
467
+ event.preventDefault();
468
+ window.history.pushState(null, "", href);
469
+ document.getElementById(getSectionAnchorId(entry.sectionKey))?.scrollIntoView({ block: "start" });
470
+ }