@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.
- package/amplify/auth/resource.js +17 -4
- package/amplify/auth/resource.ts +21 -4
- package/amplify/data/resource.js +52 -70
- package/amplify/data/resource.ts +57 -70
- package/amplify/data/schema.js +52 -58
- package/amplify/data/schema.ts +57 -58
- package/amplify/functions/console-chat-responder/py/execute_tactus_runner.py +0 -10
- package/amplify/functions/console-chat-responder/resource.js +0 -7
- package/amplify/functions/console-chat-responder/resource.ts +0 -8
- package/amplify/functions/console-chat-responder/src/main.rs +0 -9
- package/amplify/functions/content-actions/handler.py +149 -0
- package/amplify/functions/content-actions/requirements.txt +4 -0
- package/amplify/functions/content-actions/resource.js +40 -0
- package/amplify/functions/content-actions/resource.ts +54 -0
- package/amplify/functions/shared/python-bundle.js +17 -2
- package/amplify/functions/shared/python-bundle.ts +23 -2
- package/amplify/rebuild-trigger-settings.js +22 -0
- package/amplify/rebuild-trigger-settings.ts +42 -0
- package/amplify/site-backend-config.ts +12 -1
- package/amplify/site-backend.js +51 -27
- package/amplify/site-backend.ts +66 -34
- package/amplify/storage/resource.js +3 -0
- package/amplify/storage/resource.ts +3 -0
- package/app/%5F_preview/[[...path]]/route.ts +8 -0
- package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +4 -2
- package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -0
- package/app/api/revalidate/route.ts +8 -7
- package/app/articles/[slug]/page.tsx +6 -4
- package/app/blog-rhythm.css +278 -0
- package/app/globals.css +22 -4
- package/app/layout.tsx +24 -12
- package/app/newsroom/articles/[id]/page.tsx +15 -0
- package/app/newsroom/articles/page.tsx +13 -0
- package/app/page.tsx +12 -4
- package/app/robots.ts +11 -0
- package/bin/papyrus-app.mjs +12 -2
- package/bin/papyrus-infra.mjs +74 -18
- package/components/archive-shell.tsx +2 -22
- package/components/article-video.tsx +247 -3
- package/components/blog-page-background.tsx +23 -6
- package/components/markus-ir-preview.tsx +300 -0
- package/components/news-desk-page.tsx +1 -4
- package/components/newspaper.tsx +6 -2
- package/components/newsroom-access-gate.tsx +66 -0
- package/components/newsroom-article-editor.tsx +741 -0
- package/components/newsroom-articles-view.tsx +129 -0
- package/components/newsroom-ops-overview.tsx +28 -11
- package/components/newsroom-ops-shell.tsx +3 -1
- package/components/pictogram-figure.tsx +9 -2
- package/components/presentation-header.tsx +135 -0
- package/components/presentation-rhythm-hrule.tsx +17 -0
- package/components/settings-page.tsx +2 -1
- package/components/staging-banner.tsx +11 -0
- package/components/topic-steering-workspace.tsx +1 -59
- package/components/use-blog-header-obstacles.ts +81 -0
- package/components/use-brand-fonts-ready.ts +28 -0
- package/components/use-rhythm-overlay.ts +29 -0
- package/infra/amplify-app-shell.js +285 -80
- package/infra/amplify-app-shell.ts +326 -113
- package/infra/build-specs.js +94 -0
- package/infra/build-specs.ts +141 -0
- package/infra/github-oidc-provider.js +24 -0
- package/infra/github-oidc-provider.ts +30 -0
- package/infra/index.js +3 -0
- package/infra/site-config.js +273 -0
- package/infra/site-config.ts +336 -0
- package/lib/blog-feature-solver.ts +459 -0
- package/lib/blog-rhythm.ts +134 -0
- package/lib/brand-fonts-watch.ts +43 -0
- package/lib/brand-fonts.ts +112 -0
- package/lib/cached-content-repository.ts +14 -1
- package/lib/category-repository.ts +1 -93
- package/lib/content-actions-client.ts +98 -0
- package/lib/content-actions-demo.ts +190 -0
- package/lib/content-repository.ts +6 -0
- package/lib/content-source-context.ts +26 -0
- package/lib/content-types.ts +4 -0
- package/lib/edition-routes.ts +31 -29
- package/lib/empty-theme.css +1 -0
- package/lib/graphql-content-repository.ts +239 -43
- package/lib/layout-scenarios.ts +8 -8
- package/lib/newspaper-layout.ts +3 -2
- package/lib/newsroom-articles.ts +325 -0
- package/lib/newsroom-demo-articles.ts +90 -0
- package/lib/newsroom-media-backend.ts +142 -0
- package/lib/newsroom-media.ts +182 -0
- package/lib/newsroom-nav.ts +3 -0
- package/lib/papyrus-web-locations.ts +7 -6
- package/lib/presentation-footer.ts +3 -2
- package/lib/pretext-layout.ts +12 -3
- package/lib/reader-base-path.ts +24 -0
- package/lib/reader-static-params.ts +8 -0
- package/lib/renderer.ts +7 -0
- package/lib/revalidate-secret.ts +59 -0
- package/lib/root-route-redirect.ts +16 -0
- package/lib/site-brand-tenant.ts +3 -22
- package/lib/site-brand.ts +95 -42
- package/lib/site-env.ts +52 -0
- package/lib/staging-gate.ts +37 -0
- package/lib/staging-gated-path.ts +22 -0
- package/lib/staging-preview-object.ts +98 -0
- package/lib/video-mode.ts +42 -0
- package/lib/video-script.ts +77 -0
- package/middleware.ts +56 -42
- package/package.json +40 -1
- package/renderers/pretext/article-page.tsx +40 -9
- package/renderers/pretext/presentation-shared.tsx +113 -0
- package/renderers/pretext/presentation-shell.tsx +18 -112
- package/renderers/pretext/rhythm-article-page.tsx +165 -0
- package/renderers/pretext/rhythm-blog-presentation.tsx +279 -0
- package/renderers/pretext/rhythm-shell-style.ts +35 -0
- package/routes.manifest.json +37 -2
- package/scripts/videoml/build-browser-bundle.mjs +22 -0
- package/scripts/videoml/build-preview-bundle.mjs +50 -0
- package/scripts/videoml/bundle-common.mjs +129 -0
- package/scripts/videoml/preview/preview-entry.tsx +381 -0
- package/scripts/videoml/preview/preview-stage-fit.ts +28 -0
- package/scripts/videoml/preview/preview-timing.ts +42 -0
- package/scripts/videoml/preview.html +77 -0
- package/scripts/videoml/standard-entry.tsx +46 -0
- package/src/sync.mjs +30 -6
- package/src/with-papyrus.mjs +15 -2
- package/amplify/functions/graphql-jwt-authorizer/handler.ts +0 -193
- package/amplify/functions/graphql-jwt-authorizer/resource.js +0 -14
- package/amplify/functions/graphql-jwt-authorizer/resource.ts +0 -15
- package/publications/anth_us/brand.ts +0 -68
- package/publications/anth_us/theme.css +0 -66
- package/publications/pilobol_us/brand.ts +0 -37
- package/publications/pilobol_us/theme.css +0 -140
- package/publications/threat_intelligence/blog-defense/graph.ts +0 -636
- package/publications/threat_intelligence/blog-defense/layout.ts +0 -934
- package/publications/threat_intelligence/blog-defense/page-background.tsx +0 -590
- package/publications/threat_intelligence/brand.ts +0 -40
- package/publications/threat_intelligence/pictograms/art.tsx +0 -608
- package/publications/threat_intelligence/pictograms/figure.tsx +0 -82
- package/publications/threat_intelligence/pictograms/registry.ts +0 -30
- package/publications/threat_intelligence/pictograms/system.tsx +0 -305
- package/publications/threat_intelligence/seed/seed-edition-content.json +0 -629
- package/publications/threat_intelligence/theme.css +0 -1440
- /package/{publications/papyrus/theme.css → app/papyrus-theme.css} +0 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
export const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
|
2
|
+
|
|
3
|
+
export const IMAGE_CONTENT_TYPES: Record<string, string> = {
|
|
4
|
+
png: "image/png",
|
|
5
|
+
jpg: "image/jpeg",
|
|
6
|
+
jpeg: "image/jpeg",
|
|
7
|
+
gif: "image/gif",
|
|
8
|
+
webp: "image/webp",
|
|
9
|
+
avif: "image/avif",
|
|
10
|
+
svg: "image/svg+xml",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type ImageFileLike = { name: string; size: number };
|
|
14
|
+
|
|
15
|
+
function extensionOf(filename: string): string {
|
|
16
|
+
const dot = filename.lastIndexOf(".");
|
|
17
|
+
return dot === -1 ? "" : filename.slice(dot + 1).toLowerCase();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function contentTypeForFilename(filename: string): string | null {
|
|
21
|
+
return IMAGE_CONTENT_TYPES[extensionOf(filename)] ?? null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function isSvgFilename(filename: string): boolean {
|
|
25
|
+
return extensionOf(filename) === "svg";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function sanitizeSegment(value: string): string {
|
|
29
|
+
return value
|
|
30
|
+
.toLowerCase()
|
|
31
|
+
.replace(/[^a-z0-9._-]+/g, "-")
|
|
32
|
+
.replace(/-{2,}/g, "-")
|
|
33
|
+
.replace(/^[-.]+|-+$/g, "");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function srcPathFor(slug: string, filename: string, existingSrcPaths: Iterable<string> = []): string {
|
|
37
|
+
const taken = new Set(existingSrcPaths);
|
|
38
|
+
const extension = sanitizeSegment(extensionOf(filename));
|
|
39
|
+
const dot = filename.lastIndexOf(".");
|
|
40
|
+
const rawStem = dot === -1 ? filename : filename.slice(0, dot);
|
|
41
|
+
const stem = sanitizeSegment(rawStem) || "image";
|
|
42
|
+
const directory = sanitizeSegment(slug) || "article";
|
|
43
|
+
const suffix = extension ? `.${extension}` : "";
|
|
44
|
+
let candidate = `assets/${directory}/${stem}${suffix}`;
|
|
45
|
+
for (let counter = 2; taken.has(candidate); counter += 1) {
|
|
46
|
+
candidate = `assets/${directory}/${stem}-${counter}${suffix}`;
|
|
47
|
+
}
|
|
48
|
+
return candidate;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function sha256Hex(data: string | ArrayBuffer): Promise<string> {
|
|
52
|
+
const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;
|
|
53
|
+
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
|
54
|
+
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function mediaIdFor(itemId: string, srcPath: string): Promise<string> {
|
|
58
|
+
return `media-${itemId}-${(await sha256Hex(srcPath)).slice(0, 10)}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function validateImageFile(file: ImageFileLike): string | null {
|
|
62
|
+
if (contentTypeForFilename(file.name) === null) {
|
|
63
|
+
return "Choose a PNG, JPEG, GIF, WebP, AVIF or SVG image.";
|
|
64
|
+
}
|
|
65
|
+
if (file.size <= 0) return "That file is empty.";
|
|
66
|
+
if (file.size > MAX_IMAGE_BYTES) return "Images can be at most 10 MB.";
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function altAttributeValue(alt: string): string {
|
|
71
|
+
return alt.replace(/[\r\n]+/g, " ").replace(/"/g, "'").replace(/[{}]/g, "");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function imageDirective(srcPath: string, alt: string): string {
|
|
75
|
+
return `::image{src="${srcPath}" alt="${altAttributeValue(alt)}" layout="inline"}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type MediaAssetFields = {
|
|
79
|
+
id: string;
|
|
80
|
+
itemId: string;
|
|
81
|
+
type: "image";
|
|
82
|
+
role: "body";
|
|
83
|
+
sortKey: string;
|
|
84
|
+
storagePath: string;
|
|
85
|
+
alt: string;
|
|
86
|
+
width?: number;
|
|
87
|
+
height?: number;
|
|
88
|
+
metadata: string;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export function mediaSortKey(position: number, srcPath: string): string {
|
|
92
|
+
return `${String(position).padStart(3, "0")}#${srcPath}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export type SelectionInsertion = { body: string; altSelectionStart: number };
|
|
96
|
+
|
|
97
|
+
function countEdgeNewlines(text: string, fromEnd: boolean): number {
|
|
98
|
+
let count = 0;
|
|
99
|
+
while (count < 2 && (fromEnd ? text[text.length - 1 - count] : text[count]) === "\n") count += 1;
|
|
100
|
+
return count;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function insertDirectiveAtSelection(
|
|
104
|
+
body: string,
|
|
105
|
+
selectionStart: number,
|
|
106
|
+
selectionEnd: number,
|
|
107
|
+
directive: string,
|
|
108
|
+
): SelectionInsertion {
|
|
109
|
+
const before = body.slice(0, selectionStart);
|
|
110
|
+
const after = body.slice(selectionEnd);
|
|
111
|
+
const prefix = before === "" ? "" : "\n".repeat(2 - countEdgeNewlines(before, true));
|
|
112
|
+
const suffix = after === "" ? "\n" : "\n".repeat(2 - countEdgeNewlines(after, false));
|
|
113
|
+
const directiveStart = before.length + prefix.length;
|
|
114
|
+
return {
|
|
115
|
+
body: `${before}${prefix}${directive}${suffix}${after}`,
|
|
116
|
+
altSelectionStart: directiveStart + directive.indexOf('alt="') + 'alt="'.length,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export type MediaAssetRecord = {
|
|
121
|
+
id: string;
|
|
122
|
+
sortKey: string;
|
|
123
|
+
storagePath: string | null;
|
|
124
|
+
alt: string;
|
|
125
|
+
srcPath: string | null;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export type NewsroomMediaBackend = {
|
|
129
|
+
uploadImage: (input: { storagePath: string; data: Blob; contentType: string }) => Promise<void>;
|
|
130
|
+
createMediaAsset: (fields: MediaAssetFields) => Promise<void>;
|
|
131
|
+
listMediaAssets: (itemId: string) => Promise<MediaAssetRecord[]>;
|
|
132
|
+
imageUrl: (storagePath: string) => Promise<string>;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
export type ImageDimensionReader = (file: Blob, filename: string) => Promise<{ width: number; height: number } | null>;
|
|
136
|
+
|
|
137
|
+
export type UploadedImage = { srcPath: string; directive: string; imageUrl: string };
|
|
138
|
+
|
|
139
|
+
export async function uploadArticleImage(
|
|
140
|
+
backend: NewsroomMediaBackend,
|
|
141
|
+
readDimensions: ImageDimensionReader,
|
|
142
|
+
input: { itemId: string; slug: string; file: File },
|
|
143
|
+
): Promise<UploadedImage> {
|
|
144
|
+
const problem = validateImageFile(input.file);
|
|
145
|
+
if (problem) throw new Error(problem);
|
|
146
|
+
const contentType = contentTypeForFilename(input.file.name) as string;
|
|
147
|
+
const existing = await backend.listMediaAssets(input.itemId);
|
|
148
|
+
const srcPath = srcPathFor(
|
|
149
|
+
input.slug,
|
|
150
|
+
input.file.name,
|
|
151
|
+
existing.map((asset) => asset.srcPath).filter((path): path is string => path !== null),
|
|
152
|
+
);
|
|
153
|
+
const bytes = await input.file.arrayBuffer();
|
|
154
|
+
const sha256 = await sha256Hex(bytes);
|
|
155
|
+
const dimensions = await readDimensions(input.file, input.file.name);
|
|
156
|
+
const storagePath = `media/${srcPath}`;
|
|
157
|
+
await backend.uploadImage({ storagePath, data: input.file, contentType });
|
|
158
|
+
await backend.createMediaAsset({
|
|
159
|
+
id: await mediaIdFor(input.itemId, srcPath),
|
|
160
|
+
itemId: input.itemId,
|
|
161
|
+
type: "image",
|
|
162
|
+
role: "body",
|
|
163
|
+
sortKey: mediaSortKey(existing.length + 1, srcPath),
|
|
164
|
+
storagePath,
|
|
165
|
+
alt: input.file.name,
|
|
166
|
+
...(dimensions ?? {}),
|
|
167
|
+
metadata: JSON.stringify({ srcPath, sha256, bytes: bytes.byteLength, contentType }),
|
|
168
|
+
});
|
|
169
|
+
return { srcPath, directive: imageDirective(srcPath, ""), imageUrl: await backend.imageUrl(storagePath) };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function readImageDimensions(file: Blob, filename: string): Promise<{ width: number; height: number } | null> {
|
|
173
|
+
if (isSvgFilename(filename)) return null;
|
|
174
|
+
try {
|
|
175
|
+
const bitmap = await createImageBitmap(file);
|
|
176
|
+
const size = { width: bitmap.width, height: bitmap.height };
|
|
177
|
+
bitmap.close();
|
|
178
|
+
return size;
|
|
179
|
+
} catch {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
package/lib/newsroom-nav.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { newsroomHref, toPublicNewsroomPath } from "./newsroom-base-path";
|
|
|
2
2
|
|
|
3
3
|
export type NewsroomNavTabId =
|
|
4
4
|
| "overview"
|
|
5
|
+
| "articles"
|
|
5
6
|
| "messages"
|
|
6
7
|
| "insights"
|
|
7
8
|
| "assignments"
|
|
@@ -22,12 +23,14 @@ export type NewsroomNavItem = {
|
|
|
22
23
|
|
|
23
24
|
export const NEWSROOM_OPS_NAV: NewsroomNavItem[] = [
|
|
24
25
|
{ id: "overview", label: "Overview", detail: "Desk home", href: newsroomHref(), mobilePrimary: true },
|
|
26
|
+
{ id: "articles", label: "Articles", detail: "Drafts & published", href: newsroomHref("articles"), mobilePrimary: true },
|
|
25
27
|
{ id: "assignments", label: "Assignments", detail: "Work queue", href: newsroomHref("assignments"), mobilePrimary: true },
|
|
26
28
|
{ id: "references", label: "References", detail: "Knowledge base", href: newsroomHref("references"), mobilePrimary: true },
|
|
27
29
|
{ id: "messages", label: "Messages", detail: "Forum", href: newsroomHref("messages") },
|
|
28
30
|
{ id: "insights", label: "Insights", detail: "Research threads", href: newsroomHref("insights") },
|
|
29
31
|
{ id: "topics", label: "Topics", detail: "Taxonomy", href: newsroomHref("topics"), mobilePrimary: true },
|
|
30
32
|
{ id: "concepts", label: "Concepts", detail: "Ontology", href: newsroomHref("concepts") },
|
|
33
|
+
{ id: "search", label: "Search", detail: "Knowledge base", href: newsroomHref("search") },
|
|
31
34
|
{ id: "administration", label: "Administration", detail: "Users & policies", href: newsroomHref("administration") },
|
|
32
35
|
];
|
|
33
36
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { toInternalReaderPath, toPublicReaderPath } from "./reader-base-path";
|
|
1
2
|
import { PAPYRUS_OBJECT_KINDS, type PapyrusObjectKind } from "./papyrus-object-kinds";
|
|
2
3
|
import {
|
|
3
4
|
buildNewsroomIndexWebPath,
|
|
@@ -52,7 +53,7 @@ export function buildWebUiContext(webPath: string): PapyrusWebUiContext {
|
|
|
52
53
|
export function webPathToPapyrusLocation(webPath: string): PapyrusWebUiContext {
|
|
53
54
|
const normalized = normalizeWebPath(webPath);
|
|
54
55
|
const url = new URL(normalized, "https://papyrus.local");
|
|
55
|
-
const pathname = url.pathname || "/";
|
|
56
|
+
const pathname = toInternalReaderPath(url.pathname || "/");
|
|
56
57
|
|
|
57
58
|
if (pathname === "/" || pathname === "") {
|
|
58
59
|
return location("papyrus://site/home", normalized, { label: "Papyrus home" });
|
|
@@ -81,7 +82,7 @@ export function webPathToPapyrusLocation(webPath: string): PapyrusWebUiContext {
|
|
|
81
82
|
}
|
|
82
83
|
|
|
83
84
|
if (!pathname.startsWith("/newsroom")) {
|
|
84
|
-
const tail = pathname.replace(/^\/+/, "");
|
|
85
|
+
const tail = (url.pathname || "/").replace(/^\/+/, "");
|
|
85
86
|
return location(`papyrus://site/path/${encodeURIComponent(tail)}`, normalized, { label: pathname });
|
|
86
87
|
}
|
|
87
88
|
|
|
@@ -215,9 +216,9 @@ export function papyrusUriToWebPath(uri: string): PapyrusUriToWebPathResult {
|
|
|
215
216
|
const { kind, id } = parsed;
|
|
216
217
|
|
|
217
218
|
if (kind === "site") {
|
|
218
|
-
if (id === "home") return { ok: true, papyrusLocationUri: raw, webPath: "/" };
|
|
219
|
-
if (id === "archive") return { ok: true, papyrusLocationUri: raw, webPath: "/archive" };
|
|
220
|
-
if (id === "settings") return { ok: true, papyrusLocationUri: raw, webPath: "/settings" };
|
|
219
|
+
if (id === "home") return { ok: true, papyrusLocationUri: raw, webPath: toPublicReaderPath("/") };
|
|
220
|
+
if (id === "archive") return { ok: true, papyrusLocationUri: raw, webPath: toPublicReaderPath("/archive") };
|
|
221
|
+
if (id === "settings") return { ok: true, papyrusLocationUri: raw, webPath: toPublicReaderPath("/settings") };
|
|
221
222
|
if (id.startsWith("path/")) {
|
|
222
223
|
return { ok: true, papyrusLocationUri: raw, webPath: `/${decodeURIComponent(id.slice("path/".length))}` };
|
|
223
224
|
}
|
|
@@ -449,7 +450,7 @@ function objectKindToWebPath(kind: PapyrusObjectKind, objectId: string): string
|
|
|
449
450
|
if (kind === "category") return `/newsroom/topics?category=${encoded}`;
|
|
450
451
|
if (kind === "semanticNode") return `/newsroom/concepts?node=${encoded}`;
|
|
451
452
|
if (kind === "newsroomSection") return `/newsroom/sections/${encoded}`;
|
|
452
|
-
if (kind === "item") return `/articles/${encoded}
|
|
453
|
+
if (kind === "item") return toPublicReaderPath(`/articles/${encoded}`);
|
|
453
454
|
return `/newsroom?object=${encodeURIComponent(kind)}:${encoded}`;
|
|
454
455
|
}
|
|
455
456
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getEditionSectionItems } from "./edition-sections";
|
|
2
2
|
import type { EditionContent } from "./content-types";
|
|
3
3
|
import type { PublicationItem } from "./publication-items";
|
|
4
|
+
import { toPublicReaderPath } from "./reader-base-path";
|
|
4
5
|
|
|
5
6
|
export type PresentationFooterEntry = {
|
|
6
7
|
section: string;
|
|
@@ -43,9 +44,9 @@ export type PresentationFooterGeometry = {
|
|
|
43
44
|
};
|
|
44
45
|
|
|
45
46
|
export const PRESENTATION_FOOTER_UTILITIES: PresentationFooterUtilityEntry[] = [
|
|
46
|
-
{ id: "archive", label: "Archive", href: "/archive", disabled: false },
|
|
47
|
+
{ id: "archive", label: "Archive", href: toPublicReaderPath("/archive"), disabled: false },
|
|
47
48
|
{ id: "newsDesk", label: "Newsroom", href: "/newsroom", disabled: false },
|
|
48
|
-
{ id: "settings", label: "Settings", href: "/settings", disabled: false },
|
|
49
|
+
{ id: "settings", label: "Settings", href: toPublicReaderPath("/settings"), disabled: false },
|
|
49
50
|
{ id: "login", label: "LOGIN", disabled: true },
|
|
50
51
|
];
|
|
51
52
|
|
package/lib/pretext-layout.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
clearCache,
|
|
2
3
|
layoutNextLine,
|
|
3
4
|
prepareWithSegments,
|
|
4
5
|
type LayoutCursor,
|
|
@@ -78,14 +79,18 @@ export function layoutAllTextLines({
|
|
|
78
79
|
linePaintHeight,
|
|
79
80
|
fontSize,
|
|
80
81
|
fontFamily,
|
|
81
|
-
|
|
82
|
+
obstacles = [],
|
|
83
|
+
}: Omit<LayoutTextLinesOptions, "cursor" | "maxHeight" | "obstacles"> & { obstacles?: TextObstacle[] }): TextLine[] {
|
|
82
84
|
const lines: TextLine[] = [];
|
|
83
85
|
let cursor: LayoutCursor = { segmentIndex: 0, graphemeIndex: 0 };
|
|
84
86
|
|
|
85
87
|
for (let lineIndex = 0; lineIndex < 2000; lineIndex += 1) {
|
|
86
|
-
const
|
|
88
|
+
const y = lineIndex * lineHeight;
|
|
89
|
+
const slot = getAvailableSlot(maxWidth, y, linePaintHeight, obstacles);
|
|
90
|
+
if (!slot) continue;
|
|
91
|
+
const line = layoutNextLine(prepared, cursor, slot.width);
|
|
87
92
|
if (!line) return lines;
|
|
88
|
-
lines.push(toTextLine(line,
|
|
93
|
+
lines.push(toTextLine(line, slot.x, y, fontSize, fontFamily, lineHeight, linePaintHeight));
|
|
89
94
|
cursor = line.end;
|
|
90
95
|
}
|
|
91
96
|
|
|
@@ -177,3 +182,7 @@ function toTextLine(line: LayoutLine, x: number, y: number, fontSize: number, fo
|
|
|
177
182
|
function clamp(value: number, min: number, max: number): number {
|
|
178
183
|
return Math.max(min, Math.min(max, value));
|
|
179
184
|
}
|
|
185
|
+
|
|
186
|
+
export function clearPretextMeasurementCache(): void {
|
|
187
|
+
clearCache();
|
|
188
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { SITE_BRAND } from "./site-brand";
|
|
2
|
+
|
|
3
|
+
export function normalizeReaderBasePath(value: string | undefined | null): string {
|
|
4
|
+
const trimmed = (value ?? "").trim().replace(/\/+$/, "");
|
|
5
|
+
if (!trimmed) return "";
|
|
6
|
+
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function getReaderBasePath(): string {
|
|
10
|
+
return normalizeReaderBasePath(SITE_BRAND.readerBasePath);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function toPublicReaderPath(path: string, readerBasePath: string = getReaderBasePath()): string {
|
|
14
|
+
if (!readerBasePath) return path;
|
|
15
|
+
if (path === "/" || path === "") return readerBasePath;
|
|
16
|
+
return `${readerBasePath}${path.startsWith("/") ? path : `/${path}`}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function toInternalReaderPath(path: string, readerBasePath: string = getReaderBasePath()): string {
|
|
20
|
+
if (!readerBasePath) return path;
|
|
21
|
+
if (path === readerBasePath || path === `${readerBasePath}/`) return "/";
|
|
22
|
+
if (path.startsWith(`${readerBasePath}/`)) return path.slice(readerBasePath.length);
|
|
23
|
+
return path;
|
|
24
|
+
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import { graphqlContentRepository } from "./graphql-content-repository";
|
|
2
|
+
import { SITE_BRAND } from "./site-brand";
|
|
3
|
+
import type { RendererConfig } from "./renderer-config";
|
|
4
|
+
|
|
5
|
+
export function siteServesNextReaderRoutes(rendererKind: RendererConfig["kind"] = SITE_BRAND.renderer.kind): boolean {
|
|
6
|
+
return rendererKind !== "markus";
|
|
7
|
+
}
|
|
2
8
|
|
|
3
9
|
const MONTH_NAMES = [
|
|
4
10
|
"january",
|
|
@@ -23,6 +29,7 @@ type EditionDateStaticParam = {
|
|
|
23
29
|
|
|
24
30
|
export async function generateEditionDateStaticParams(): Promise<EditionDateStaticParam[]> {
|
|
25
31
|
if (process.env.NODE_ENV === "development") return [];
|
|
32
|
+
if (!siteServesNextReaderRoutes()) return [];
|
|
26
33
|
|
|
27
34
|
const { editions } = await graphqlContentRepository.listPublishedEditions({ limit: 100 });
|
|
28
35
|
const params: EditionDateStaticParam[] = [];
|
|
@@ -35,6 +42,7 @@ export async function generateEditionDateStaticParams(): Promise<EditionDateStat
|
|
|
35
42
|
|
|
36
43
|
export async function generateArticleStaticParams(): Promise<Array<{ slug: string }>> {
|
|
37
44
|
if (process.env.NODE_ENV === "development") return [];
|
|
45
|
+
if (!siteServesNextReaderRoutes()) return [];
|
|
38
46
|
|
|
39
47
|
const slugs = await graphqlContentRepository.listArticleSlugs();
|
|
40
48
|
return slugs.map((slug) => ({ slug }));
|
package/lib/renderer.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { EditionContent } from "./content-types";
|
|
|
4
4
|
import type { PretextLayout } from "./renderer-config";
|
|
5
5
|
import type { PresentationFooterEntry } from "./presentation-footer";
|
|
6
6
|
import type { PublicationItem } from "./publication-items";
|
|
7
|
+
import type { VideoScriptRef } from "./video-script";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Renderer is the swappable render pipeline that turns standard content
|
|
@@ -36,6 +37,8 @@ export type RenderEditionProps = {
|
|
|
36
37
|
export type ArticlePageEditionFooter = {
|
|
37
38
|
editionBasePath: string;
|
|
38
39
|
entries: PresentationFooterEntry[];
|
|
40
|
+
/** Section links for the rhythm blog layout's page header. Ignored by the classic layout. */
|
|
41
|
+
sections?: Array<{ key: string; label: string }>;
|
|
39
42
|
subtitle: string;
|
|
40
43
|
title?: string;
|
|
41
44
|
};
|
|
@@ -46,6 +49,8 @@ export type RenderArticleProps = {
|
|
|
46
49
|
backLabel?: string;
|
|
47
50
|
editionFooter?: ArticlePageEditionFooter;
|
|
48
51
|
editionDate?: string;
|
|
52
|
+
/** VideoML script for the preview player. */
|
|
53
|
+
videoScript?: VideoScriptRef | null;
|
|
49
54
|
};
|
|
50
55
|
|
|
51
56
|
export type RenderItemProps = {
|
|
@@ -54,6 +59,8 @@ export type RenderItemProps = {
|
|
|
54
59
|
backLabel?: string;
|
|
55
60
|
editionFooter?: ArticlePageEditionFooter;
|
|
56
61
|
editionDate?: string;
|
|
62
|
+
/** VideoML script for the preview player. */
|
|
63
|
+
videoScript?: VideoScriptRef | null;
|
|
57
64
|
};
|
|
58
65
|
|
|
59
66
|
/**
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { GetParameterCommand, SSMClient } from "@aws-sdk/client-ssm";
|
|
3
|
+
|
|
4
|
+
export const REVALIDATE_SECRET_PARAMETER_ENV = "PAPYRUS_REVALIDATE_SECRET_PARAMETER";
|
|
5
|
+
export const REVALIDATE_SECRET_CACHE_MILLISECONDS = 5 * 60 * 1000;
|
|
6
|
+
|
|
7
|
+
export type RevalidateSecretReader = (parameterName: string) => Promise<string | null>;
|
|
8
|
+
|
|
9
|
+
type CachedSecret = { parameterName: string; value: string; expiresAt: number };
|
|
10
|
+
|
|
11
|
+
let cachedSecret: CachedSecret | null = null;
|
|
12
|
+
let defaultSsmClient: SSMClient | null = null;
|
|
13
|
+
|
|
14
|
+
export function resetRevalidateSecretCache(): void {
|
|
15
|
+
cachedSecret = null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function readSecretFromSsm(parameterName: string): Promise<string | null> {
|
|
19
|
+
defaultSsmClient ??= new SSMClient({});
|
|
20
|
+
const response = await defaultSsmClient.send(new GetParameterCommand({ Name: parameterName, WithDecryption: true }));
|
|
21
|
+
const value = response.Parameter?.Value?.trim();
|
|
22
|
+
return value ? value : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function loadRevalidateSecret(
|
|
26
|
+
options: { environment?: Record<string, string | undefined>; readSecret?: RevalidateSecretReader; now?: number } = {},
|
|
27
|
+
): Promise<string | null> {
|
|
28
|
+
const environment = options.environment ?? process.env;
|
|
29
|
+
const parameterName = environment[REVALIDATE_SECRET_PARAMETER_ENV]?.trim();
|
|
30
|
+
if (!parameterName) return null;
|
|
31
|
+
const now = options.now ?? Date.now();
|
|
32
|
+
if (cachedSecret && cachedSecret.parameterName === parameterName && cachedSecret.expiresAt > now) {
|
|
33
|
+
return cachedSecret.value;
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
const value = (await (options.readSecret ?? readSecretFromSsm)(parameterName))?.trim();
|
|
37
|
+
if (!value) return null;
|
|
38
|
+
cachedSecret = { parameterName, value, expiresAt: now + REVALIDATE_SECRET_CACHE_MILLISECONDS };
|
|
39
|
+
return value;
|
|
40
|
+
} catch (error) {
|
|
41
|
+
console.error(`Revalidate secret parameter could not be read (${error instanceof Error ? error.name : "error"}).`);
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function secretsMatch(provided: string | null | undefined, expected: string): boolean {
|
|
47
|
+
const providedBytes = Buffer.from((provided ?? "").trim());
|
|
48
|
+
const expectedBytes = Buffer.from(expected);
|
|
49
|
+
return providedBytes.length === expectedBytes.length && timingSafeEqual(providedBytes, expectedBytes);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function isAuthorizedRevalidateRequest(
|
|
53
|
+
providedSecret: string | null | undefined,
|
|
54
|
+
options: Parameters<typeof loadRevalidateSecret>[0] = {},
|
|
55
|
+
): Promise<boolean> {
|
|
56
|
+
const expected = await loadRevalidateSecret(options);
|
|
57
|
+
if (!expected) return false;
|
|
58
|
+
return secretsMatch(providedSecret, expected);
|
|
59
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type RootSearchParams = Record<string, string | string[] | undefined>;
|
|
2
|
+
|
|
3
|
+
export function buildRootRedirectTarget(destination: string, searchParams: RootSearchParams | undefined): string {
|
|
4
|
+
const query = new URLSearchParams();
|
|
5
|
+
for (const [name, value] of Object.entries(searchParams ?? {})) {
|
|
6
|
+
if (Array.isArray(value)) {
|
|
7
|
+
for (const entry of value) query.append(name, entry);
|
|
8
|
+
} else if (value !== undefined) {
|
|
9
|
+
query.append(name, value);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
const serialized = query.toString();
|
|
13
|
+
if (!serialized) return destination;
|
|
14
|
+
const separator = destination.includes("?") ? "&" : "?";
|
|
15
|
+
return `${destination}${separator}${serialized}`;
|
|
16
|
+
}
|
package/lib/site-brand-tenant.ts
CHANGED
|
@@ -1,27 +1,8 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
-
import {
|
|
3
|
-
getSiteBrand,
|
|
4
|
-
resolveRuntimeSiteBrandId,
|
|
5
|
-
resolveSiteBrandId,
|
|
6
|
-
type SiteBrand,
|
|
7
|
-
type SiteBrandId,
|
|
8
|
-
} from "./site-brand";
|
|
2
|
+
import { getSiteBrand, resolveSiteBrandId, type SiteBrand, type SiteBrandId } from "./site-brand";
|
|
9
3
|
|
|
10
|
-
export
|
|
11
|
-
|
|
12
|
-
export function resolveActiveSiteBrandId(
|
|
13
|
-
cookieOverride: string | undefined | null = null,
|
|
14
|
-
envBrandId: SiteBrandId = resolveSiteBrandId(),
|
|
15
|
-
): SiteBrandId {
|
|
16
|
-
return resolveRuntimeSiteBrandId(cookieOverride) ?? envBrandId;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export function resolveActiveSiteBrand(
|
|
20
|
-
cookieOverride: string | undefined | null = null,
|
|
21
|
-
brandId?: SiteBrandId,
|
|
22
|
-
): SiteBrand {
|
|
23
|
-
const resolvedId = brandId ?? resolveActiveSiteBrandId(cookieOverride);
|
|
24
|
-
return getSiteBrand(resolvedId);
|
|
4
|
+
export function resolveActiveSiteBrand(brandId?: SiteBrandId): SiteBrand {
|
|
5
|
+
return getSiteBrand(brandId ?? resolveSiteBrandId());
|
|
25
6
|
}
|
|
26
7
|
|
|
27
8
|
export function resolveBrandConfigPath(relativePath: string): string {
|