@anthusai/papyrus 1.0.0-next.20 → 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.
@@ -1,6 +1,6 @@
1
1
  import { notFound } from "next/navigation";
2
2
  import { getSiteRenderer } from "../../../lib/site-renderer";
3
- import { getCachedArticle } from "../../../lib/cached-content-repository";
3
+ import { getCachedArticle, getCachedVideoScript } from "../../../lib/cached-content-repository";
4
4
  import { generateArticleStaticParams, siteServesNextReaderRoutes } from "../../../lib/reader-static-params";
5
5
  import { SITE_BRAND } from "../../../lib/site-brand";
6
6
 
@@ -29,9 +29,9 @@ export async function generateMetadata({ params }: ArticlePageProps) {
29
29
  export default async function ArticlePage({ params }: ArticlePageProps) {
30
30
  if (!siteServesNextReaderRoutes()) notFound();
31
31
  const { slug } = await params;
32
- const article = await getCachedArticle(slug);
32
+ const [article, videoScript] = await Promise.all([getCachedArticle(slug), getCachedVideoScript(slug)]);
33
33
  if (!article) notFound();
34
34
 
35
35
  const siteRenderer = getSiteRenderer();
36
- return <siteRenderer.renderArticle article={article} backHref="/" />;
36
+ return <siteRenderer.renderArticle article={article} videoScript={videoScript} backHref="/" />;
37
37
  }
@@ -12,6 +12,7 @@ import { ARCHIVE_PREVIEW_HEIGHT, ARCHIVE_PREVIEW_WIDTH } from "../lib/archive-ty
12
12
  import type { EditionContent, NewsDeskAppendix, NewsDeskCategoryTreeNode } from "../lib/content-types";
13
13
  import { shouldBypassImageOptimization } from "../lib/image-url";
14
14
  import type { PublicationItem } from "../lib/publication-items";
15
+ import { clearPretextMeasurementCache } from "../lib/pretext-layout";
15
16
  import { toPublicReaderPath } from "../lib/reader-base-path";
16
17
  import { SITE_BRAND } from "../lib/site-brand";
17
18
  import { resolveThemedImageSrc, type ResolvedTheme } from "../lib/themed-image";
@@ -131,7 +132,9 @@ export function Newspaper({
131
132
  if (!fonts) return;
132
133
  let cancelled = false;
133
134
  const refreshAfterFontLoad = () => {
134
- if (!cancelled) setFontRevision((revision) => revision + 1);
135
+ if (cancelled) return;
136
+ clearPretextMeasurementCache();
137
+ setFontRevision((revision) => revision + 1);
135
138
  };
136
139
  fonts.ready.then(refreshAfterFontLoad).catch(() => undefined);
137
140
  fonts.addEventListener("loadingdone", refreshAfterFontLoad);
@@ -2,29 +2,26 @@
2
2
 
3
3
  import { useEffect, useState } from "react";
4
4
  import { listBrandFontFamilies } from "../lib/brand-fonts";
5
+ import { watchBrandFontsLoaded } from "../lib/brand-fonts-watch";
6
+ import { clearPretextMeasurementCache } from "../lib/pretext-layout";
5
7
  import { SITE_BRAND } from "../lib/site-brand";
6
8
 
7
9
  /**
8
- * Returns a number that increments once the brand's declared font families
9
- * have loaded, so Pretext measurements taken with fallback metrics are
10
- * re-run with the real glyph widths. Stays 0 for brands without a font slot.
10
+ * Returns a number that increments each time the brand's declared font
11
+ * families finish loading. Pretext's width cache is cleared first, so
12
+ * measurements taken with fallback metrics are re-run with the real glyph
13
+ * widths. Stays 0 for brands without a font slot, and on the server.
11
14
  */
12
15
  export function useBrandFontsReadyVersion(): number {
13
16
  const [version, setVersion] = useState(0);
14
17
 
15
18
  useEffect(() => {
16
- const families = listBrandFontFamilies(SITE_BRAND.fonts);
17
- if (families.length === 0 || !document.fonts) return;
18
- let isCancelled = false;
19
- void Promise.all(families.map((family) => document.fonts.load(`16px ${JSON.stringify(family)}`)))
20
- .then(() => document.fonts.ready)
21
- .then(() => {
22
- if (!isCancelled) setVersion((current) => current + 1);
23
- })
24
- .catch(() => undefined);
25
- return () => {
26
- isCancelled = true;
27
- };
19
+ return watchBrandFontsLoaded({
20
+ fontFaceSet: typeof document === "undefined" ? undefined : document.fonts,
21
+ families: listBrandFontFamilies(SITE_BRAND.fonts),
22
+ clearMeasurementCaches: clearPretextMeasurementCache,
23
+ onFontsSettled: () => setVersion((current) => current + 1),
24
+ });
28
25
  }, []);
29
26
 
30
27
  return version;
@@ -0,0 +1,43 @@
1
+ export type FontFaceSetLike = {
2
+ load(font: string): Promise<unknown>;
3
+ ready: Promise<unknown>;
4
+ addEventListener(type: string, listener: () => void): void;
5
+ removeEventListener(type: string, listener: () => void): void;
6
+ };
7
+
8
+ export type WatchBrandFontsOptions = {
9
+ fontFaceSet: FontFaceSetLike | undefined;
10
+ families: string[];
11
+ clearMeasurementCaches: () => void;
12
+ onFontsSettled: () => void;
13
+ };
14
+
15
+ /**
16
+ * Clears Pretext measurement caches and then notifies the caller every time
17
+ * the declared families finish loading, so layout never reuses widths that
18
+ * were measured with fallback metrics. Clearing always happens before the
19
+ * notification, which makes the re-layout order deterministic.
20
+ */
21
+ export function watchBrandFontsLoaded({
22
+ fontFaceSet,
23
+ families,
24
+ clearMeasurementCaches,
25
+ onFontsSettled,
26
+ }: WatchBrandFontsOptions): () => void {
27
+ if (families.length === 0 || !fontFaceSet) return () => undefined;
28
+ let isCancelled = false;
29
+ const settle = () => {
30
+ if (isCancelled) return;
31
+ clearMeasurementCaches();
32
+ onFontsSettled();
33
+ };
34
+ fontFaceSet.addEventListener("loadingdone", settle);
35
+ void Promise.all(families.map((family) => fontFaceSet.load(`16px ${JSON.stringify(family)}`)))
36
+ .then(() => fontFaceSet.ready)
37
+ .then(settle)
38
+ .catch(() => undefined);
39
+ return () => {
40
+ isCancelled = true;
41
+ fontFaceSet.removeEventListener("loadingdone", settle);
42
+ };
43
+ }
@@ -10,6 +10,7 @@ import type {
10
10
  } from "./content-types";
11
11
  import { graphqlContentRepository } from "./graphql-content-repository";
12
12
  import { READER_REVALIDATE_SECONDS } from "./reader-route-config";
13
+ import type { VideoScriptRef } from "./video-script";
13
14
  import { getContentSource } from "./site-env";
14
15
 
15
16
  const bypassReaderCacheInDevelopment = process.env.NODE_ENV === "development";
@@ -88,3 +89,14 @@ export function getCachedLatestPublishedEdition(): Promise<EditionRouteSummary |
88
89
  async () => graphqlContentRepository.getLatestPublishedEdition(),
89
90
  );
90
91
  }
92
+
93
+ export function getCachedVideoScript(targetSlug: string): Promise<VideoScriptRef | null> {
94
+ if (bypassReaderCacheInDevelopment) {
95
+ return Promise.resolve(graphqlContentRepository.loadVideoScript(targetSlug)).then((result) => result ?? null);
96
+ }
97
+ return withReaderCache(
98
+ ["reader-video-script", targetSlug],
99
+ [articleCacheTag(targetSlug), ARTICLES_CACHE_TAG],
100
+ async () => graphqlContentRepository.loadVideoScript(targetSlug),
101
+ );
102
+ }
@@ -31,6 +31,9 @@ export const contentRepository: ContentRepository = {
31
31
  listArticleSlugs() {
32
32
  return graphqlContentRepository.listArticleSlugs();
33
33
  },
34
+ loadVideoScript(targetSlug: string) {
35
+ return graphqlContentRepository.loadVideoScript(targetSlug);
36
+ },
34
37
  };
35
38
 
36
39
  export function getScenarioIdParam(scenario: string | string[] | null | undefined): string | null {
@@ -104,4 +104,5 @@ export type ContentRepository = {
104
104
  getEditionArticle(options: GetEditionArticleOptions): Article | undefined | Promise<Article | undefined>;
105
105
  getEditionItem(options: GetEditionItemOptions): PublicationItem | undefined | Promise<PublicationItem | undefined>;
106
106
  listArticleSlugs(): string[] | Promise<string[]>;
107
+ loadVideoScript(targetSlug: string): VideoScriptRef | null | Promise<VideoScriptRef | null>;
107
108
  };
@@ -18,6 +18,8 @@ import {
18
18
  } from "./publication-items";
19
19
  import { BodyIrError, matchBodyImages, projectBodyIr, type BodyProjection, type BodyProjectionImage } from "./markus-body";
20
20
  import { SITE_BRAND } from "./site-brand";
21
+ import { shouldFetchVideoScripts } from "./video-mode";
22
+ import { collectVideoScriptTargets, parseVideoScriptRef, videomlItemSlug, type VideoScriptRef } from "./video-script";
21
23
  import { currentContentSource, currentRequestClient, runWithContentSource } from "./content-source-context";
22
24
  import { getContentSource } from "./site-env";
23
25
 
@@ -236,6 +238,10 @@ export const graphqlContentRepository: ContentRepository = {
236
238
  return withReaderGraphQLContext(() => loadEditionItem(editionDate, itemSlug));
237
239
  },
238
240
 
241
+ loadVideoScript(targetSlug) {
242
+ return withReaderGraphQLContext(() => loadVideoScriptForTarget(targetSlug));
243
+ },
244
+
239
245
  listArticleSlugs() {
240
246
  return withReaderGraphQLContext(async () => {
241
247
  const items = await listItemsByTypeStatus(`${ARTICLE_TYPE}#${PUBLISHED_STATUS}`);
@@ -379,6 +385,9 @@ async function loadEditionContentFromEdition(edition: GraphQLEdition): Promise<E
379
385
  const sanitizedLayoutPlan = pruneLayoutPlanUnavailableItems(layoutPlan, availableItemSlugs, edition.id);
380
386
  validateEditionLayoutPlanForItems(sanitizedLayoutPlan, items, `PublishedEdition(${edition.id}).layoutPlan`);
381
387
 
388
+ const editionVideo = await normalizeEditionVideoAsset(editionMetadata?.editionVideo);
389
+ const videoScripts = await loadEditionVideoScripts(items, editionVideo);
390
+
382
391
  return {
383
392
  id: edition.id,
384
393
  source: "graphql",
@@ -389,10 +398,38 @@ async function loadEditionContentFromEdition(edition: GraphQLEdition): Promise<E
389
398
  items,
390
399
  sections: createEditionSectionPlan(items, edition.metadata),
391
400
  suppressNewsDeskAppendix: editionMetadata?.suppressNewsDeskAppendix === true,
392
- editionVideo: await normalizeEditionVideoAsset(editionMetadata?.editionVideo),
401
+ editionVideo,
402
+ videoScripts,
393
403
  };
394
404
  }
395
405
 
406
+ function videoScriptsEnabled(): boolean {
407
+ return Boolean(SITE_BRAND.videoPlayer) && shouldFetchVideoScripts();
408
+ }
409
+
410
+ async function loadVideoScriptForTarget(targetSlug: string): Promise<VideoScriptRef | null> {
411
+ if (!videoScriptsEnabled()) return null;
412
+ const item = await getItemBySlug(videomlItemSlug(targetSlug));
413
+ if (!item || item.type !== "videoml" || !isVisibleItemStatus(item.status)) return null;
414
+ return parseVideoScriptRef(item);
415
+ }
416
+
417
+ async function loadEditionVideoScripts(
418
+ items: PublicationItem[],
419
+ editionVideo: ArticleVideoAsset | null,
420
+ ): Promise<Record<string, VideoScriptRef> | undefined> {
421
+ if (!videoScriptsEnabled()) return undefined;
422
+ const targets = collectVideoScriptTargets(items, editionVideo !== null);
423
+ const entries = await Promise.all(
424
+ targets.map(async (targetSlug) => {
425
+ const script = await loadVideoScriptForTarget(targetSlug);
426
+ return script ? ([targetSlug, script] as const) : null;
427
+ }),
428
+ );
429
+ const videoScripts = Object.fromEntries(entries.filter((entry): entry is [string, VideoScriptRef] => entry !== null));
430
+ return Object.keys(videoScripts).length > 0 ? videoScripts : undefined;
431
+ }
432
+
396
433
  async function normalizeEditionVideoAsset(value: unknown): Promise<ArticleVideoAsset | null> {
397
434
  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
398
435
  const record = value as Record<string, unknown>;
@@ -1,4 +1,5 @@
1
1
  import {
2
+ clearCache,
2
3
  layoutNextLine,
3
4
  prepareWithSegments,
4
5
  type LayoutCursor,
@@ -181,3 +182,7 @@ function toTextLine(line: LayoutLine, x: number, y: number, fontSize: number, fo
181
182
  function clamp(value: number, min: number, max: number): number {
182
183
  return Math.max(min, Math.min(max, value));
183
184
  }
185
+
186
+ export function clearPretextMeasurementCache(): void {
187
+ clearCache();
188
+ }
@@ -21,6 +21,20 @@ export type VideoScriptRef = {
21
21
  targetKind: VideoScriptTargetKind;
22
22
  };
23
23
 
24
+ export const EDITION_OVERVIEW_VIDEO_KEY = "edition-overview";
25
+
26
+ export function collectVideoScriptTargets(
27
+ items: ReadonlyArray<{ slug: string; video?: unknown }>,
28
+ hasEditionVideo: boolean,
29
+ ): string[] {
30
+ const targets = new Set<string>();
31
+ if (hasEditionVideo) targets.add(EDITION_OVERVIEW_VIDEO_KEY);
32
+ for (const item of items) {
33
+ if (item.video) targets.add(item.slug);
34
+ }
35
+ return [...targets];
36
+ }
37
+
24
38
  export function videomlItemSlug(targetSlug: string): string {
25
39
  return `${targetSlug}--videoml`;
26
40
  }
package/package.json CHANGED
@@ -133,6 +133,7 @@
133
133
  "./lib/articles": "./lib/articles.ts",
134
134
  "./lib/blog-feature-solver": "./lib/blog-feature-solver.ts",
135
135
  "./lib/blog-rhythm": "./lib/blog-rhythm.ts",
136
+ "./lib/brand-fonts-watch": "./lib/brand-fonts-watch.ts",
136
137
  "./lib/brand-fonts": "./lib/brand-fonts.ts",
137
138
  "./lib/cached-content-repository": "./lib/cached-content-repository.ts",
138
139
  "./lib/category-dashboard": "./lib/category-dashboard.ts",
@@ -292,7 +293,7 @@
292
293
  "optional": true
293
294
  }
294
295
  },
295
- "version": "1.0.0-next.20",
296
+ "version": "1.0.0-next.21",
296
297
  "dependencies": {
297
298
  "@aws-amplify/adapter-nextjs": "^1.7.3",
298
299
  "@aws-sdk/client-cognito-identity-provider": "3.1045.0",
@@ -15,7 +15,7 @@ import {
15
15
  type PublicationItem,
16
16
  } from "../../lib/publication-items";
17
17
  import { SITE_BRAND } from "../../lib/site-brand";
18
- import type { VideoScriptRef } from "../../lib/video-script";
18
+ import { EDITION_OVERVIEW_VIDEO_KEY, type VideoScriptRef } from "../../lib/video-script";
19
19
  import { ArticleVideoFigure } from "../../components/article-video";
20
20
  import { BlogPageBackground as GenericBlogPageBackground } from "../../components/blog-page-background";
21
21
  import { PictogramFigure as GenericPictogramFigure } from "../../components/pictogram-figure";
@@ -40,7 +40,6 @@ import { BLOG_RHYTHM, getFeaturedLayoutStyle, getRhythmShellStyle } from "./rhyt
40
40
  const BlogPageBackground = SITE_BRAND.components?.BlogPageBackground ?? GenericBlogPageBackground;
41
41
  const PictogramFigure = SITE_BRAND.components?.PictogramFigure ?? GenericPictogramFigure;
42
42
  const BLOG_TEXT_STYLE = createDefaultBlogTextStyle(SITE_BRAND.textFont);
43
- const EDITION_OVERVIEW_VIDEO_KEY = "edition-overview";
44
43
  const SECONDARY_PAIR_ITEM_INDEX = 2;
45
44
 
46
45
  type PapyrusTestWindow = Window & typeof globalThis & {
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.0.0-next.20",
2
+ "version": "1.0.0-next.21",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",