@anthusai/papyrus 1.0.0-next.4 → 1.0.0-next.41
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/redirect-urls.js +54 -0
- package/amplify/auth/redirect-urls.ts +66 -0
- package/amplify/auth/resource.js +20 -5
- package/amplify/auth/resource.ts +27 -5
- package/amplify/data/resource.js +28 -70
- package/amplify/data/resource.ts +29 -70
- package/amplify/data/schema.js +33 -58
- package/amplify/data/schema.ts +34 -58
- package/amplify/functions/category-action/handler.ts +5 -3
- 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 +45 -3
- package/amplify/functions/shared/reference-curation-metadata.ts +29 -0
- package/amplify/inbound-email/ses-intake.js +86 -0
- package/amplify/inbound-email/ses-intake.ts +110 -0
- package/amplify/rebuild-trigger-settings.js +22 -0
- package/amplify/rebuild-trigger-settings.ts +42 -0
- package/amplify/resolver-waves.js +26 -0
- package/amplify/resolver-waves.ts +34 -0
- package/amplify/site-backend-config.ts +36 -1
- package/amplify/site-backend-names.js +103 -0
- package/amplify/site-backend-names.ts +166 -0
- package/amplify/site-backend.js +93 -170
- package/amplify/site-backend.ts +126 -196
- package/amplify/storage/resource.js +3 -0
- package/amplify/storage/resource.ts +3 -0
- package/amplify/storage-backups/construct.js +31 -0
- package/amplify/storage-backups/construct.ts +47 -0
- package/app/%5F_preview/[[...path]]/route.ts +8 -0
- package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +2 -0
- package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -0
- package/app/api/media/[...path]/route.ts +5 -16
- 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 +21 -0
- package/app/layout.tsx +16 -0
- package/app/newsroom/[section]/[...selection]/page.tsx +6 -5
- package/app/newsroom/[section]/page.tsx +4 -3
- package/app/newsroom/articles/[id]/page.tsx +15 -0
- package/app/newsroom/articles/page.tsx +13 -0
- package/app/newsroom/layout.tsx +4 -9
- package/app/page.tsx +18 -5
- package/app/robots.ts +2 -5
- package/bin/papyrus-app.mjs +12 -2
- package/bin/papyrus-infra.mjs +29 -3
- package/components/amplify-client-provider.tsx +4 -22
- 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-taxonomy-client.ts +51 -0
- package/components/newspaper.tsx +6 -2
- package/components/newsroom-access-gate.tsx +67 -0
- package/components/newsroom-article-editor.tsx +741 -0
- package/components/newsroom-articles-view.tsx +129 -0
- package/components/newsroom-client-shell.tsx +12 -0
- package/components/newsroom-ops-overview.tsx +28 -11
- package/components/newsroom-ops-shell.tsx +3 -1
- package/components/newsroom-references-view.tsx +83 -8
- package/components/papyrus-console-shell.tsx +4 -2
- package/components/pictogram-figure.tsx +9 -2
- package/components/presentation-header.tsx +135 -0
- package/components/presentation-rhythm-hrule.tsx +17 -0
- package/components/reference-cyclotron-status.tsx +109 -0
- package/components/reference-relevance-review.tsx +75 -0
- package/components/settings-page.tsx +2 -1
- package/components/topic-steering-workspace.tsx +54 -64
- 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 +228 -50
- package/infra/amplify-app-shell.ts +256 -53
- package/infra/build-specs.js +15 -5
- package/infra/build-specs.ts +18 -7
- package/infra/github-oidc-provider.js +24 -0
- package/infra/github-oidc-provider.ts +30 -0
- package/infra/index.js +1 -0
- package/infra/site-config.js +236 -11
- package/infra/site-config.ts +276 -15
- package/lib/amplify-outputs-path.ts +5 -1
- 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 +12 -0
- package/lib/category-repository.ts +42 -93
- package/lib/content-actions-demo.ts +190 -0
- package/lib/content-repository.ts +3 -0
- package/lib/content-source-context.ts +12 -2
- package/lib/content-types.ts +4 -0
- package/lib/edition-routes.ts +31 -29
- package/lib/graphql-content-repository.ts +61 -9
- package/lib/media-route-response.ts +45 -0
- package/lib/newspaper-layout.ts +3 -2
- package/lib/newsroom-articles.ts +325 -0
- package/lib/newsroom-base-path.ts +12 -3
- package/lib/newsroom-category-drilldown.ts +3 -2
- package/lib/newsroom-demo-articles.ts +90 -0
- package/lib/newsroom-forum-routes.ts +6 -4
- package/lib/newsroom-index-filters.ts +14 -10
- package/lib/newsroom-media-backend.ts +142 -0
- package/lib/newsroom-media.ts +182 -0
- package/lib/newsroom-nav.ts +3 -0
- package/lib/oauth-redirect-priority.ts +40 -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 +13 -0
- package/lib/reference-policy.ts +5 -0
- package/lib/relevance-decisions.ts +103 -0
- package/lib/relevance-status.ts +63 -0
- package/lib/renderer.ts +7 -0
- package/lib/revalidate-secret.ts +59 -0
- package/lib/robots-policy.ts +14 -0
- package/lib/root-route-redirect.ts +16 -0
- package/lib/semantic-graph.ts +7 -0
- package/lib/site-brand.ts +51 -0
- package/lib/staging-gated-path.ts +23 -0
- package/lib/staging-preview-object.ts +114 -0
- package/lib/video-mode.ts +42 -0
- package/lib/video-script.ts +77 -0
- package/middleware.ts +23 -18
- package/package.json +45 -1
- package/renderers/pretext/article-page.tsx +39 -3
- package/renderers/pretext/presentation-shared.tsx +113 -0
- package/renderers/pretext/presentation-shell.tsx +15 -102
- 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 +45 -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 +19 -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
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { generateServerClientUsingCookies } from "@aws-amplify/adapter-nextjs/api";
|
|
1
2
|
import { generateClient } from "aws-amplify/data";
|
|
2
3
|
import type { Schema } from "../amplify/data/resource";
|
|
3
4
|
import type { Article, ArticleImage, ArticleImageAsset, ArticleImageLayout, ArticleImageThemeVariants, ArticleVideoAsset, ArticleVideoThemeVariants } from "./articles";
|
|
@@ -17,7 +18,9 @@ import {
|
|
|
17
18
|
} from "./publication-items";
|
|
18
19
|
import { BodyIrError, matchBodyImages, projectBodyIr, type BodyProjection, type BodyProjectionImage } from "./markus-body";
|
|
19
20
|
import { SITE_BRAND } from "./site-brand";
|
|
20
|
-
import {
|
|
21
|
+
import { shouldFetchVideoScripts } from "./video-mode";
|
|
22
|
+
import { collectVideoScriptTargets, parseVideoScriptRef, videomlItemSlug, type VideoScriptRef } from "./video-script";
|
|
23
|
+
import { currentContentSource, currentRequestClient, runWithContentSource } from "./content-source-context";
|
|
21
24
|
import { getContentSource } from "./site-env";
|
|
22
25
|
|
|
23
26
|
const DEFAULT_EDITION_SLUG = "current";
|
|
@@ -143,12 +146,19 @@ function isVisibleItemStatus(status: string): boolean {
|
|
|
143
146
|
}
|
|
144
147
|
|
|
145
148
|
async function withReaderGraphQLContext<T>(operation: () => Promise<T>): Promise<T> {
|
|
146
|
-
const { runWithAmplifyServerContext } = getAmplifyServerRuntime();
|
|
149
|
+
const { runWithAmplifyServerContext, config } = getAmplifyServerRuntime();
|
|
147
150
|
const source = getContentSource();
|
|
148
|
-
|
|
151
|
+
if (source === "drafts") {
|
|
152
|
+
// Drafts are read as the signed-in visitor: the client reads the session from the request cookies, and it is
|
|
153
|
+
// kept in the request's async context (never a module global) so one visitor's session cannot serve another.
|
|
154
|
+
const { cookies } = await import("next/headers");
|
|
155
|
+
await cookies();
|
|
156
|
+
const client = generateServerClientUsingCookies<Schema>({ config, cookies, authMode: "userPool" });
|
|
157
|
+
return runWithContentSource(source, operation, client);
|
|
158
|
+
}
|
|
149
159
|
return runWithContentSource(source, () =>
|
|
150
160
|
runWithAmplifyServerContext({
|
|
151
|
-
nextServerContext,
|
|
161
|
+
nextServerContext: null,
|
|
152
162
|
operation: async () => {
|
|
153
163
|
readerContextClient = generateClient<Schema>({ authMode: authMode() });
|
|
154
164
|
try {
|
|
@@ -229,6 +239,10 @@ export const graphqlContentRepository: ContentRepository = {
|
|
|
229
239
|
return withReaderGraphQLContext(() => loadEditionItem(editionDate, itemSlug));
|
|
230
240
|
},
|
|
231
241
|
|
|
242
|
+
loadVideoScript(targetSlug) {
|
|
243
|
+
return withReaderGraphQLContext(() => loadVideoScriptForTarget(targetSlug));
|
|
244
|
+
},
|
|
245
|
+
|
|
232
246
|
listArticleSlugs() {
|
|
233
247
|
return withReaderGraphQLContext(async () => {
|
|
234
248
|
const items = await listItemsByTypeStatus(`${ARTICLE_TYPE}#${PUBLISHED_STATUS}`);
|
|
@@ -241,6 +255,8 @@ export const graphqlContentRepository: ContentRepository = {
|
|
|
241
255
|
};
|
|
242
256
|
|
|
243
257
|
function getClient(): DataClient {
|
|
258
|
+
const requestClient = currentRequestClient<DataClient>();
|
|
259
|
+
if (requestClient) return requestClient;
|
|
244
260
|
if (readerContextClient) return readerContextClient;
|
|
245
261
|
|
|
246
262
|
if (!buildTimeClient) {
|
|
@@ -370,6 +386,9 @@ async function loadEditionContentFromEdition(edition: GraphQLEdition): Promise<E
|
|
|
370
386
|
const sanitizedLayoutPlan = pruneLayoutPlanUnavailableItems(layoutPlan, availableItemSlugs, edition.id);
|
|
371
387
|
validateEditionLayoutPlanForItems(sanitizedLayoutPlan, items, `PublishedEdition(${edition.id}).layoutPlan`);
|
|
372
388
|
|
|
389
|
+
const editionVideo = await normalizeEditionVideoAsset(editionMetadata?.editionVideo);
|
|
390
|
+
const videoScripts = await loadEditionVideoScripts(items, editionVideo);
|
|
391
|
+
|
|
373
392
|
return {
|
|
374
393
|
id: edition.id,
|
|
375
394
|
source: "graphql",
|
|
@@ -380,10 +399,38 @@ async function loadEditionContentFromEdition(edition: GraphQLEdition): Promise<E
|
|
|
380
399
|
items,
|
|
381
400
|
sections: createEditionSectionPlan(items, edition.metadata),
|
|
382
401
|
suppressNewsDeskAppendix: editionMetadata?.suppressNewsDeskAppendix === true,
|
|
383
|
-
editionVideo
|
|
402
|
+
editionVideo,
|
|
403
|
+
videoScripts,
|
|
384
404
|
};
|
|
385
405
|
}
|
|
386
406
|
|
|
407
|
+
function videoScriptsEnabled(): boolean {
|
|
408
|
+
return Boolean(SITE_BRAND.videoPlayer) && shouldFetchVideoScripts();
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function loadVideoScriptForTarget(targetSlug: string): Promise<VideoScriptRef | null> {
|
|
412
|
+
if (!videoScriptsEnabled()) return null;
|
|
413
|
+
const item = await getItemBySlug(videomlItemSlug(targetSlug));
|
|
414
|
+
if (!item || item.type !== "videoml" || !isVisibleItemStatus(item.status)) return null;
|
|
415
|
+
return parseVideoScriptRef(item);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async function loadEditionVideoScripts(
|
|
419
|
+
items: PublicationItem[],
|
|
420
|
+
editionVideo: ArticleVideoAsset | null,
|
|
421
|
+
): Promise<Record<string, VideoScriptRef> | undefined> {
|
|
422
|
+
if (!videoScriptsEnabled()) return undefined;
|
|
423
|
+
const targets = collectVideoScriptTargets(items, editionVideo !== null);
|
|
424
|
+
const entries = await Promise.all(
|
|
425
|
+
targets.map(async (targetSlug) => {
|
|
426
|
+
const script = await loadVideoScriptForTarget(targetSlug);
|
|
427
|
+
return script ? ([targetSlug, script] as const) : null;
|
|
428
|
+
}),
|
|
429
|
+
);
|
|
430
|
+
const videoScripts = Object.fromEntries(entries.filter((entry): entry is [string, VideoScriptRef] => entry !== null));
|
|
431
|
+
return Object.keys(videoScripts).length > 0 ? videoScripts : undefined;
|
|
432
|
+
}
|
|
433
|
+
|
|
387
434
|
async function normalizeEditionVideoAsset(value: unknown): Promise<ArticleVideoAsset | null> {
|
|
388
435
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
389
436
|
const record = value as Record<string, unknown>;
|
|
@@ -808,7 +855,7 @@ async function normalizeArticle(item: GraphQLItem, mediaAssets: GraphQLMediaAsse
|
|
|
808
855
|
|
|
809
856
|
function projectPublishedItemBody(item: GraphQLItem): BodyProjection {
|
|
810
857
|
if (item.bodyIr === null || item.bodyIr === undefined || item.bodyIr === "") {
|
|
811
|
-
throw new BodyIrError(`PublishedItem ${item.slug} has no bodyIr; run: papyrus content convert-bodies`);
|
|
858
|
+
throw new BodyIrError(`PublishedItem ${item.slug} has no bodyIr; run: papyrus ops content convert-bodies`);
|
|
812
859
|
}
|
|
813
860
|
return projectBodyIr(item.bodyIr);
|
|
814
861
|
}
|
|
@@ -930,15 +977,20 @@ function parseAttachmentPointer(value: unknown): ContentAttachmentPointer | null
|
|
|
930
977
|
};
|
|
931
978
|
}
|
|
932
979
|
|
|
933
|
-
function readInlineExcerpt(editorial: unknown): string | null {
|
|
980
|
+
export function readInlineExcerpt(editorial: unknown): string | null {
|
|
934
981
|
const parsed = parseObjectMetadata(editorial);
|
|
935
982
|
if (!parsed) return null;
|
|
936
983
|
const direct = parsed.excerpt;
|
|
937
984
|
if (typeof direct === "string" && direct.trim()) return direct.trim();
|
|
985
|
+
const custom = parsed.customExcerpt;
|
|
986
|
+
if (typeof custom === "string" && custom.trim()) return custom.trim();
|
|
938
987
|
const newsroom = parsed.newsroom;
|
|
939
988
|
if (!newsroom || typeof newsroom !== "object" || Array.isArray(newsroom)) return null;
|
|
940
|
-
const
|
|
941
|
-
|
|
989
|
+
const newsroomRecord = newsroom as Record<string, unknown>;
|
|
990
|
+
const nestedExcerpt = newsroomRecord.excerpt;
|
|
991
|
+
if (typeof nestedExcerpt === "string" && nestedExcerpt.trim()) return nestedExcerpt.trim();
|
|
992
|
+
const nestedCustom = newsroomRecord.customExcerpt;
|
|
993
|
+
return typeof nestedCustom === "string" && nestedCustom.trim() ? nestedCustom.trim() : null;
|
|
942
994
|
}
|
|
943
995
|
|
|
944
996
|
function normalizePublicationItemType(value: string): Exclude<PublicationItemType, "article"> | null {
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
const PROXIED_IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "webp", "avif", "svg"]);
|
|
2
|
+
|
|
3
|
+
export type MediaRouteDependencies = {
|
|
4
|
+
signStorageUrl: (storagePath: string) => Promise<string>;
|
|
5
|
+
fetchUpstream: (signedUrl: string) => Promise<Response>;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export function isProxiedImagePath(storagePath: string): boolean {
|
|
9
|
+
const lastSegment = storagePath.split("/").pop() ?? "";
|
|
10
|
+
const dotIndex = lastSegment.lastIndexOf(".");
|
|
11
|
+
if (dotIndex < 0) {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
return PROXIED_IMAGE_EXTENSIONS.has(lastSegment.slice(dotIndex + 1).toLowerCase());
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function buildMediaRouteResponse(
|
|
18
|
+
storagePath: string,
|
|
19
|
+
dependencies: MediaRouteDependencies,
|
|
20
|
+
): Promise<Response> {
|
|
21
|
+
const signedUrl = await dependencies.signStorageUrl(storagePath);
|
|
22
|
+
|
|
23
|
+
if (!isProxiedImagePath(storagePath)) {
|
|
24
|
+
return new Response(null, {
|
|
25
|
+
status: 307,
|
|
26
|
+
headers: { location: signedUrl, "cache-control": "private, max-age=300" },
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const upstream = await dependencies.fetchUpstream(signedUrl);
|
|
31
|
+
if (!upstream.ok) {
|
|
32
|
+
return Response.json(
|
|
33
|
+
{ error: `Upstream media fetch failed (${upstream.status}).` },
|
|
34
|
+
{ status: upstream.status === 404 ? 404 : 502 },
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const headers = new Headers();
|
|
39
|
+
const contentType = upstream.headers.get("content-type");
|
|
40
|
+
if (contentType) {
|
|
41
|
+
headers.set("content-type", contentType);
|
|
42
|
+
}
|
|
43
|
+
headers.set("cache-control", "public, max-age=3600, stale-while-revalidate=86400");
|
|
44
|
+
return new Response(upstream.body, { status: 200, headers });
|
|
45
|
+
}
|
package/lib/newspaper-layout.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { toPublicReaderPath } from "./reader-base-path";
|
|
1
2
|
import type { ArticleImageAsset, ArticleImageThemeVariants } from "./articles";
|
|
2
3
|
import {
|
|
3
4
|
type ArticleFrameBlockSpec,
|
|
@@ -896,9 +897,9 @@ function solveFrontFooter(blocks: SolvedBlock[], config: LayoutConfig, pageNumbe
|
|
|
896
897
|
: Math.min(entries.length, Math.max(1, config.columnCount <= 1 ? 1 : config.columnCount <= 3 ? 2 : 4));
|
|
897
898
|
const sectionRows = entries.length === 0 ? 0 : Math.ceil(entries.length / sectionColumns);
|
|
898
899
|
const utilityEntries: SolvedFrontFooterUtilityEntry[] = [
|
|
899
|
-
{ id: "archive", label: "Archive", href: "/archive", disabled: false },
|
|
900
|
+
{ id: "archive", label: "Archive", href: toPublicReaderPath("/archive"), disabled: false },
|
|
900
901
|
{ id: "newsDesk", label: "Newsroom", href: "/newsroom", disabled: false },
|
|
901
|
-
{ id: "settings", label: "Settings", href: "/settings", disabled: false },
|
|
902
|
+
{ id: "settings", label: "Settings", href: toPublicReaderPath("/settings"), disabled: false },
|
|
902
903
|
{ id: "login", label: "LOGIN", disabled: true },
|
|
903
904
|
];
|
|
904
905
|
const rowCount = 1 + Math.max(sectionRows, utilityEntries.length);
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import { generateClient } from "aws-amplify/data";
|
|
2
|
+
import type { Schema } from "../amplify/data/resource";
|
|
3
|
+
import {
|
|
4
|
+
deriveMarkus,
|
|
5
|
+
publishItem,
|
|
6
|
+
saveItemDraft,
|
|
7
|
+
unpublishItem,
|
|
8
|
+
type ContentActionError,
|
|
9
|
+
} from "./content-actions-client";
|
|
10
|
+
import { createDemoArticlesBackend } from "./content-actions-demo";
|
|
11
|
+
|
|
12
|
+
export type ArticleStatus = "draft" | "published";
|
|
13
|
+
export type ArticleType = "article" | "page";
|
|
14
|
+
|
|
15
|
+
export const ARTICLE_TYPES: ArticleType[] = ["article", "page"];
|
|
16
|
+
|
|
17
|
+
export type NewsroomArticleRow = {
|
|
18
|
+
id: string;
|
|
19
|
+
type: string;
|
|
20
|
+
slug: string;
|
|
21
|
+
section: string | null;
|
|
22
|
+
title: string;
|
|
23
|
+
status: ArticleStatus;
|
|
24
|
+
pending: boolean;
|
|
25
|
+
updatedAt: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type ArticleItemSummary = {
|
|
29
|
+
id: string;
|
|
30
|
+
lineageId?: string | null;
|
|
31
|
+
type: string;
|
|
32
|
+
slug: string;
|
|
33
|
+
section?: string | null;
|
|
34
|
+
title?: string | null;
|
|
35
|
+
status: string;
|
|
36
|
+
contentHash?: string | null;
|
|
37
|
+
updatedAt?: string | null;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type PublishedItemSummary = {
|
|
41
|
+
id: string;
|
|
42
|
+
itemLineageId?: string | null;
|
|
43
|
+
sourceItemId?: string | null;
|
|
44
|
+
metadata?: unknown;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type NewsroomArticleEditorState = {
|
|
48
|
+
id: string | null;
|
|
49
|
+
type: string;
|
|
50
|
+
section: string;
|
|
51
|
+
slug: string;
|
|
52
|
+
frontMatterYaml: string;
|
|
53
|
+
bodyMarkus: string;
|
|
54
|
+
contentHash: string | null;
|
|
55
|
+
status: ArticleStatus;
|
|
56
|
+
versionNumber: number | null;
|
|
57
|
+
dirty: boolean;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type EditableArticle = {
|
|
61
|
+
id: string;
|
|
62
|
+
type: string;
|
|
63
|
+
section: string | null;
|
|
64
|
+
slug: string;
|
|
65
|
+
frontMatterYaml: string;
|
|
66
|
+
bodyMarkus: string;
|
|
67
|
+
contentHash: string | null;
|
|
68
|
+
status: ArticleStatus;
|
|
69
|
+
versionNumber: number | null;
|
|
70
|
+
aliases: string[];
|
|
71
|
+
everPublished: boolean;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export type ArticlesBackend = {
|
|
75
|
+
actions: {
|
|
76
|
+
deriveMarkus: typeof deriveMarkus;
|
|
77
|
+
saveItemDraft: typeof saveItemDraft;
|
|
78
|
+
publishItem: typeof publishItem;
|
|
79
|
+
unpublishItem: typeof unpublishItem;
|
|
80
|
+
};
|
|
81
|
+
listRows: () => Promise<NewsroomArticleRow[]>;
|
|
82
|
+
loadArticle: (id: string) => Promise<EditableArticle>;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export function slugify(value: string): string {
|
|
86
|
+
return value
|
|
87
|
+
.normalize("NFKD")
|
|
88
|
+
.replace(/[^\x00-\x7F]/g, "")
|
|
89
|
+
.toLowerCase()
|
|
90
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
91
|
+
.replace(/^-+|-+$/g, "");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function suggestSlug(frontMatterYaml: string): string {
|
|
95
|
+
for (const line of frontMatterYaml.split("\n")) {
|
|
96
|
+
const match = /^title\s*:\s*(.*)$/.exec(line);
|
|
97
|
+
if (!match) continue;
|
|
98
|
+
let value = match[1].trim();
|
|
99
|
+
if (value.length >= 2 && (value[0] === '"' || value[0] === "'") && value.endsWith(value[0])) {
|
|
100
|
+
value = value.slice(1, -1);
|
|
101
|
+
}
|
|
102
|
+
return slugify(value);
|
|
103
|
+
}
|
|
104
|
+
return "";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function errorLine(errors: ContentActionError[]): number | null {
|
|
108
|
+
for (const error of errors) {
|
|
109
|
+
if (typeof error.line === "number") return error.line;
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function sourceLineOffset(frontMatterYaml: string | null): number {
|
|
115
|
+
const yamlText = (frontMatterYaml ?? "").replace(/^\n+|\n+$/g, "");
|
|
116
|
+
if (!yamlText.trim()) return 0;
|
|
117
|
+
return yamlText.split("\n").length + 2;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export type ErrorLocation = { field: "frontMatterYaml" | "bodyMarkus"; line: number | null };
|
|
121
|
+
|
|
122
|
+
export function locateError(error: ContentActionError, frontMatterYaml: string | null): ErrorLocation {
|
|
123
|
+
if (error.code === "front-matter") return { field: "frontMatterYaml", line: null };
|
|
124
|
+
if (error.line === null) return { field: "bodyMarkus", line: null };
|
|
125
|
+
const bodyLine = error.line - sourceLineOffset(frontMatterYaml);
|
|
126
|
+
if (bodyLine >= 1) return { field: "bodyMarkus", line: bodyLine };
|
|
127
|
+
return { field: "frontMatterYaml", line: Math.max(error.line - 1, 1) };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function emptyArticle(): NewsroomArticleEditorState {
|
|
131
|
+
return {
|
|
132
|
+
id: null,
|
|
133
|
+
type: "article",
|
|
134
|
+
section: "",
|
|
135
|
+
slug: "",
|
|
136
|
+
frontMatterYaml: "title: \n",
|
|
137
|
+
bodyMarkus: "",
|
|
138
|
+
contentHash: null,
|
|
139
|
+
status: "draft",
|
|
140
|
+
versionNumber: null,
|
|
141
|
+
dirty: false,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function parseJsonObject(value: unknown): Record<string, unknown> {
|
|
146
|
+
if (typeof value === "string") {
|
|
147
|
+
try {
|
|
148
|
+
const parsed = JSON.parse(value);
|
|
149
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : {};
|
|
150
|
+
} catch {
|
|
151
|
+
return {};
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function hasPendingChanges(item: ArticleItemSummary, published: PublishedItemSummary | null): boolean {
|
|
158
|
+
if (!published) return false;
|
|
159
|
+
return item.contentHash !== parseJsonObject(published.metadata).sourceContentHash;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function articleStatusLabel(item: ArticleItemSummary, published: PublishedItemSummary | null): string {
|
|
163
|
+
if (item.status !== "published") return "Draft";
|
|
164
|
+
return hasPendingChanges(item, published) ? "Published, unpublished changes" : "Published";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function rowFromItem(item: ArticleItemSummary, published: PublishedItemSummary | null): NewsroomArticleRow {
|
|
168
|
+
const status: ArticleStatus = item.status === "published" ? "published" : "draft";
|
|
169
|
+
return {
|
|
170
|
+
id: item.id,
|
|
171
|
+
type: item.type,
|
|
172
|
+
slug: item.slug,
|
|
173
|
+
section: item.section ?? null,
|
|
174
|
+
title: item.title || item.slug,
|
|
175
|
+
status,
|
|
176
|
+
pending: status === "published" && hasPendingChanges(item, published),
|
|
177
|
+
updatedAt: item.updatedAt ?? "",
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function sortRowsByUpdatedAtDescending(rows: NewsroomArticleRow[]): NewsroomArticleRow[] {
|
|
182
|
+
return [...rows].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function rowStatusLabel(row: NewsroomArticleRow): string {
|
|
186
|
+
if (row.status === "draft") return "Draft";
|
|
187
|
+
return row.pending ? "Published, unpublished changes" : "Published";
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
type ListPage<T> = { data?: (T | null)[] | null; nextToken?: string | null; errors?: { message?: string }[] | null };
|
|
191
|
+
type ListOptions = { authMode: "userPool"; nextToken?: string | null; limit?: number };
|
|
192
|
+
|
|
193
|
+
export type ArticlesDataClient = {
|
|
194
|
+
models: {
|
|
195
|
+
Item: {
|
|
196
|
+
listItemsByTypeStatusAndPublishedAt: (
|
|
197
|
+
input: { typeStatus: string },
|
|
198
|
+
options: ListOptions,
|
|
199
|
+
) => Promise<ListPage<ArticleItemSummary>>;
|
|
200
|
+
get: (
|
|
201
|
+
input: { id: string },
|
|
202
|
+
options: { authMode: "userPool" },
|
|
203
|
+
) => Promise<{ data?: Record<string, unknown> | null; errors?: { message?: string }[] | null }>;
|
|
204
|
+
};
|
|
205
|
+
PublishedItem: {
|
|
206
|
+
listPublishedItemsByTypeStatusAndPublishedAt: (
|
|
207
|
+
input: { typeStatus: string },
|
|
208
|
+
options: ListOptions,
|
|
209
|
+
) => Promise<ListPage<PublishedItemSummary>>;
|
|
210
|
+
};
|
|
211
|
+
};
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const NEWSROOM_ARTICLE_TYPE_STATUSES = ["article#draft", "article#published", "page#draft", "page#published"];
|
|
215
|
+
const NEWSROOM_PUBLISHED_TYPE_STATUSES = ["article#published", "page#published"];
|
|
216
|
+
|
|
217
|
+
async function collectPages<T>(
|
|
218
|
+
fetchPage: (options: ListOptions) => Promise<ListPage<T>>,
|
|
219
|
+
): Promise<T[]> {
|
|
220
|
+
const collected: T[] = [];
|
|
221
|
+
let nextToken: string | null | undefined = null;
|
|
222
|
+
do {
|
|
223
|
+
const page: ListPage<T> = await fetchPage({ authMode: "userPool", nextToken, limit: 500 });
|
|
224
|
+
if (page.errors && page.errors.length > 0) {
|
|
225
|
+
throw new Error(page.errors.map((error) => error.message ?? "GraphQL error").join("; "));
|
|
226
|
+
}
|
|
227
|
+
for (const row of page.data ?? []) {
|
|
228
|
+
if (row) collected.push(row);
|
|
229
|
+
}
|
|
230
|
+
nextToken = page.nextToken;
|
|
231
|
+
} while (nextToken);
|
|
232
|
+
return collected;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export async function loadArticleRows(client: ArticlesDataClient): Promise<NewsroomArticleRow[]> {
|
|
236
|
+
const itemGroups = await Promise.all(
|
|
237
|
+
NEWSROOM_ARTICLE_TYPE_STATUSES.map((typeStatus) =>
|
|
238
|
+
collectPages((options) => client.models.Item.listItemsByTypeStatusAndPublishedAt({ typeStatus }, options)),
|
|
239
|
+
),
|
|
240
|
+
);
|
|
241
|
+
const publishedGroups = await Promise.all(
|
|
242
|
+
NEWSROOM_PUBLISHED_TYPE_STATUSES.map((typeStatus) =>
|
|
243
|
+
collectPages((options) =>
|
|
244
|
+
client.models.PublishedItem.listPublishedItemsByTypeStatusAndPublishedAt({ typeStatus }, options),
|
|
245
|
+
),
|
|
246
|
+
),
|
|
247
|
+
);
|
|
248
|
+
const publishedByLineage = new Map<string, PublishedItemSummary>();
|
|
249
|
+
for (const published of publishedGroups.flat()) {
|
|
250
|
+
const lineage = published.itemLineageId ?? published.sourceItemId;
|
|
251
|
+
if (lineage) publishedByLineage.set(lineage, published);
|
|
252
|
+
}
|
|
253
|
+
const rows = itemGroups
|
|
254
|
+
.flat()
|
|
255
|
+
.map((item) => rowFromItem(item, publishedByLineage.get(item.lineageId ?? item.id) ?? null));
|
|
256
|
+
return sortRowsByUpdatedAtDescending(rows);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export async function loadEditableArticle(client: ArticlesDataClient, id: string): Promise<EditableArticle> {
|
|
260
|
+
const response = await client.models.Item.get({ id }, { authMode: "userPool" });
|
|
261
|
+
if (response.errors && response.errors.length > 0) {
|
|
262
|
+
throw new Error(response.errors.map((error) => error.message ?? "GraphQL error").join("; "));
|
|
263
|
+
}
|
|
264
|
+
const record = response.data;
|
|
265
|
+
if (!record) throw new Error("That article was not found.");
|
|
266
|
+
const metadata = parseJsonObject(record.metadata);
|
|
267
|
+
const status: ArticleStatus = record.status === "published" ? "published" : "draft";
|
|
268
|
+
return {
|
|
269
|
+
id: String(record.id),
|
|
270
|
+
type: String(record.type),
|
|
271
|
+
section: typeof record.section === "string" ? record.section : null,
|
|
272
|
+
slug: String(record.slug),
|
|
273
|
+
frontMatterYaml: typeof metadata.frontMatterYaml === "string" ? metadata.frontMatterYaml : "",
|
|
274
|
+
bodyMarkus: typeof record.bodyMarkus === "string" ? record.bodyMarkus : "",
|
|
275
|
+
contentHash: typeof record.contentHash === "string" ? record.contentHash : null,
|
|
276
|
+
status,
|
|
277
|
+
versionNumber: typeof record.versionNumber === "number" ? record.versionNumber : null,
|
|
278
|
+
aliases: Array.isArray(record.aliases) ? record.aliases.map(String) : [],
|
|
279
|
+
everPublished: status === "published",
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function createLiveArticlesBackend(): ArticlesBackend {
|
|
284
|
+
let client: ArticlesDataClient | null = null;
|
|
285
|
+
const dataClient = () => {
|
|
286
|
+
if (!client) client = generateClient<Schema>() as unknown as ArticlesDataClient;
|
|
287
|
+
return client;
|
|
288
|
+
};
|
|
289
|
+
return {
|
|
290
|
+
actions: { deriveMarkus, saveItemDraft, publishItem, unpublishItem },
|
|
291
|
+
listRows: () => loadArticleRows(dataClient()),
|
|
292
|
+
loadArticle: (id) => loadEditableArticle(dataClient(), id),
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
let liveBackend: ArticlesBackend | null = null;
|
|
297
|
+
let demoBackend: ArticlesBackend | null = null;
|
|
298
|
+
|
|
299
|
+
export function resolveArticlesBackend(demo: boolean): ArticlesBackend {
|
|
300
|
+
if (demo) {
|
|
301
|
+
demoBackend ??= createDemoArticlesBackend();
|
|
302
|
+
return demoBackend;
|
|
303
|
+
}
|
|
304
|
+
liveBackend ??= createLiveArticlesBackend();
|
|
305
|
+
return liveBackend;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function stagingPreviewUrl(
|
|
309
|
+
stagingBaseUrl: string | undefined,
|
|
310
|
+
type: string,
|
|
311
|
+
slug: string,
|
|
312
|
+
staticSite: boolean,
|
|
313
|
+
): string | null {
|
|
314
|
+
if (!stagingBaseUrl || !slug || type !== "article") return null;
|
|
315
|
+
const base = stagingBaseUrl.replace(/\/+$/, "");
|
|
316
|
+
return staticSite ? `${base}/articles/${slug}.html` : `${base}/articles/${slug}`;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function uniqueSections(rows: NewsroomArticleRow[]): string[] {
|
|
320
|
+
const sections = new Set<string>();
|
|
321
|
+
for (const row of rows) {
|
|
322
|
+
if (row.section) sections.add(row.section);
|
|
323
|
+
}
|
|
324
|
+
return [...sections].sort();
|
|
325
|
+
}
|
|
@@ -19,13 +19,22 @@ export function usesNewsroomRootPaths(): boolean {
|
|
|
19
19
|
export function toPublicNewsroomPath(internalPath: string): string {
|
|
20
20
|
const publicBase = getPublicNewsroomBasePath();
|
|
21
21
|
if (publicBase !== "") return internalPath;
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
const suffixStart = internalPath.search(/[?#]/);
|
|
23
|
+
const pathOnly = suffixStart === -1 ? internalPath : internalPath.slice(0, suffixStart);
|
|
24
|
+
const suffix = suffixStart === -1 ? "" : internalPath.slice(suffixStart);
|
|
25
|
+
if (pathOnly === "/newsroom" || pathOnly === "/newsroom/") return `/${suffix}`;
|
|
26
|
+
if (pathOnly.startsWith("/newsroom/")) {
|
|
27
|
+
return `${pathOnly.slice("/newsroom".length) || "/"}${suffix}`;
|
|
25
28
|
}
|
|
26
29
|
return internalPath;
|
|
27
30
|
}
|
|
28
31
|
|
|
32
|
+
/** Maps a browser pathname to the internal `/newsroom/...` form on CMS-only hosts; identity elsewhere. */
|
|
33
|
+
export function browserPathToInternalNewsroomPath(browserPath: string): string {
|
|
34
|
+
if (!usesNewsroomRootPaths()) return browserPath;
|
|
35
|
+
return toInternalNewsroomPath(browserPath);
|
|
36
|
+
}
|
|
37
|
+
|
|
29
38
|
export function toInternalNewsroomPath(publicPath: string): string {
|
|
30
39
|
const publicBase = getPublicNewsroomBasePath();
|
|
31
40
|
const normalized = publicPath.startsWith("/") ? publicPath : `/${publicPath}`;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { toPublicNewsroomPath } from "./newsroom-base-path";
|
|
1
2
|
import type {
|
|
2
3
|
CategorySteeringCategory,
|
|
3
4
|
SemanticRelationRecord,
|
|
@@ -93,11 +94,11 @@ export function uniqueNeighborGroupsForCategoryContext(graph: CategoryGraph, con
|
|
|
93
94
|
export function topicHref(rootCategoryKey: string, selectedCategoryKey?: string | null): string {
|
|
94
95
|
const encodedRoot = encodeURIComponent(rootCategoryKey);
|
|
95
96
|
const encodedSelected = selectedCategoryKey && selectedCategoryKey !== rootCategoryKey ? `/${encodeURIComponent(selectedCategoryKey)}` : "";
|
|
96
|
-
return `/newsroom/topics/${encodedRoot}${encodedSelected}
|
|
97
|
+
return toPublicNewsroomPath(`/newsroom/topics/${encodedRoot}${encodedSelected}`);
|
|
97
98
|
}
|
|
98
99
|
|
|
99
100
|
export function categoryDrilldownHref(section: "references" | "concepts", categoryKey: string): string {
|
|
100
|
-
return `/newsroom/${section}/${encodeURIComponent(categoryKey)}
|
|
101
|
+
return toPublicNewsroomPath(`/newsroom/${section}/${encodeURIComponent(categoryKey)}`);
|
|
101
102
|
}
|
|
102
103
|
|
|
103
104
|
function matchesCategorySelection(category: CategorySteeringCategory, selection: string): boolean {
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
export type DemoArticleRecord = {
|
|
2
|
+
id: string;
|
|
3
|
+
type: string;
|
|
4
|
+
slug: string;
|
|
5
|
+
section: string | null;
|
|
6
|
+
title: string;
|
|
7
|
+
status: "draft" | "published";
|
|
8
|
+
frontMatterYaml: string;
|
|
9
|
+
bodyMarkus: string;
|
|
10
|
+
contentHash: string;
|
|
11
|
+
publishedContentHash: string | null;
|
|
12
|
+
versionNumber: number | null;
|
|
13
|
+
everPublished: boolean;
|
|
14
|
+
updatedAt: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function demoContentHash(frontMatterYaml: string, bodyMarkus: string): string {
|
|
18
|
+
let hash = 5381;
|
|
19
|
+
const text = `${frontMatterYaml}\u0000${bodyMarkus}`;
|
|
20
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
21
|
+
hash = ((hash << 5) + hash + text.charCodeAt(index)) >>> 0;
|
|
22
|
+
}
|
|
23
|
+
return `demo:${hash.toString(16)}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function demoRecord(
|
|
27
|
+
fields: Pick<DemoArticleRecord, "id" | "slug" | "section" | "title" | "bodyMarkus" | "updatedAt"> & {
|
|
28
|
+
type?: string;
|
|
29
|
+
status: "draft" | "published";
|
|
30
|
+
publishedBody?: string;
|
|
31
|
+
versionNumber: number | null;
|
|
32
|
+
},
|
|
33
|
+
): DemoArticleRecord {
|
|
34
|
+
const frontMatterYaml = `title: ${fields.title}\n`;
|
|
35
|
+
const contentHash = demoContentHash(frontMatterYaml, fields.bodyMarkus);
|
|
36
|
+
const publishedContentHash = fields.status === "published"
|
|
37
|
+
? demoContentHash(frontMatterYaml, fields.publishedBody ?? fields.bodyMarkus)
|
|
38
|
+
: null;
|
|
39
|
+
return {
|
|
40
|
+
id: fields.id,
|
|
41
|
+
type: fields.type ?? "article",
|
|
42
|
+
slug: fields.slug,
|
|
43
|
+
section: fields.section,
|
|
44
|
+
title: fields.title,
|
|
45
|
+
status: fields.status,
|
|
46
|
+
frontMatterYaml,
|
|
47
|
+
bodyMarkus: fields.bodyMarkus,
|
|
48
|
+
contentHash,
|
|
49
|
+
publishedContentHash,
|
|
50
|
+
versionNumber: fields.versionNumber,
|
|
51
|
+
everPublished: fields.status === "published",
|
|
52
|
+
updatedAt: fields.updatedAt,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function createDemoArticleRecords(): DemoArticleRecord[] {
|
|
57
|
+
return [
|
|
58
|
+
demoRecord({
|
|
59
|
+
id: "demo-article-changed",
|
|
60
|
+
slug: "forty-billion-dollars",
|
|
61
|
+
section: "business",
|
|
62
|
+
title: "Forty Billion Dollars",
|
|
63
|
+
bodyMarkus: "The raise doubled again this quarter.\n\nAnalysts expect the pace to slow.\n",
|
|
64
|
+
publishedBody: "The raise doubled this quarter.\n",
|
|
65
|
+
status: "published",
|
|
66
|
+
versionNumber: 2,
|
|
67
|
+
updatedAt: "2026-10-05T16:00:00.000Z",
|
|
68
|
+
}),
|
|
69
|
+
demoRecord({
|
|
70
|
+
id: "demo-article-published",
|
|
71
|
+
slug: "harbor-cranes-return",
|
|
72
|
+
section: "local",
|
|
73
|
+
title: "Harbor Cranes Return",
|
|
74
|
+
bodyMarkus: "# Harbor Cranes Return\n\nThe cranes were lifted back into place on Monday.\n",
|
|
75
|
+
status: "published",
|
|
76
|
+
versionNumber: 1,
|
|
77
|
+
updatedAt: "2026-10-04T12:00:00.000Z",
|
|
78
|
+
}),
|
|
79
|
+
demoRecord({
|
|
80
|
+
id: "demo-article-draft",
|
|
81
|
+
slug: "council-budget-notes",
|
|
82
|
+
section: "politics",
|
|
83
|
+
title: "Council Budget Notes",
|
|
84
|
+
bodyMarkus: "Notes from the budget session.\n",
|
|
85
|
+
status: "draft",
|
|
86
|
+
versionNumber: null,
|
|
87
|
+
updatedAt: "2026-10-03T09:00:00.000Z",
|
|
88
|
+
}),
|
|
89
|
+
];
|
|
90
|
+
}
|