diff --git a/apis/tracker/api.ts b/apis/tracker/api.ts index 9d17db3a..146f87ad 100644 --- a/apis/tracker/api.ts +++ b/apis/tracker/api.ts @@ -3,22 +3,51 @@ import type { TrackerSourceDatasetResponse, TrackerSourceStudy, } from "@/types/network"; +import { readFile } from "fs/promises"; +import { PHASE_PRODUCTION_BUILD } from "next/constants"; +import { inspect } from "util"; +import { PUBLISHED_ATLASES_SNAPSHOT_ENV } from "./constants"; import type { PublishedAtlas } from "./types"; // Module-level cache for published atlases during build. Stores the in-flight -// Promise so concurrent callers share a single fetch. +// Promise so concurrent callers share a single load. let publishedAtlasesPromise: Promise | null = null; +// Advice appended when `next build` runs without a snapshot path. +const BUILD_HINT = + "(run `npm run build`, which snapshots the published atlases before `next build`)"; + +// Advice appended when the build's snapshot file is gone, e.g. removed by a tmp +// cleaner mid-build. +const SNAPSHOT_MISSING_HINT = + "(the snapshot `scripts/build.sh` wrote for this build was removed; run the build again)"; + +// Fields an item must carry as strings for the publication gate to match on +// it. Items missing one are skipped, and a body where every item is missing one +// fails the build, so a renamed field cannot make every atlas silently read as +// unpublished. +const PUBLISHED_ATLAS_MATCH_KEYS = [ + "shortNameSlug", + "version", +] as const satisfies readonly (keyof PublishedAtlas)[]; + /** - * Returns the tracker base URL, read at call time to ensure env vars are loaded. - * @returns tracker base URL. + * Fetches and validates the tracker's published atlases. Used directly by + * `next dev` and by the snapshot script that runs before `next build`. + * Failures (network error, non-2xx, malformed body, missing tracker URL) are + * rethrown with the reason in the message. + * @returns published atlases. */ -function getTrackerUrl(): string { - const url = process.env.NEXT_PUBLIC_ATLAS_TRACKER_URL; - if (!url) { - throw new Error("NEXT_PUBLIC_ATLAS_TRACKER_URL is not configured"); +export async function fetchPublishedAtlases(): Promise { + try { + const data = await fetchTrackerApi( + "/api/published-atlases", + "published atlases" + ); + return parsePublishedAtlases(data); + } catch (err) { + throw publishedAtlasesError(err); } - return url; } /** @@ -80,38 +109,52 @@ export function fetchTrackerSourceStudies( } /** - * Returns the cached list of published atlases, fetching on first call. - * On failure (network error, non-2xx, malformed body), logs a warning and - * resolves to an empty list so callers can fall back to "no atlases - * published" — protecting non-tracker pages from tracker outages. - * @returns list of published atlases. + * Finds the published atlas with the given slug and version. When two records + * match, the first is returned. + * @param shortNameSlug - Atlas short name slug (e.g., "gut"). + * @param version - Atlas version (e.g., "v1.0"). + * @returns the published atlas, or undefined when it is not published. + */ +async function findPublishedAtlas( + shortNameSlug: string, + version: string +): Promise { + const atlases = await getPublishedAtlases(); + return atlases.find( + (atlas) => + atlas.shortNameSlug === shortNameSlug && atlas.version === version + ); +} + +/** + * Returns the cached published atlases, loading them on first call. Failures + * are rethrown so the build fails deterministically rather than silently + * omitting tracker atlases; the rejected promise is cleared so a long-lived + * `next dev` server can retry on the next request. + * @returns published atlases. */ function getPublishedAtlases(): Promise { if (!publishedAtlasesPromise) { - publishedAtlasesPromise = fetchTrackerApi( - "/api/published-atlases", - "published atlases" - ) - .then((data) => { - if (!Array.isArray(data)) { - console.warn( - `Tracker /api/published-atlases returned a non-array body; treating as empty.` - ); - return []; - } - return data as PublishedAtlas[]; - }) - .catch((err) => { - console.warn( - `Failed to fetch published atlases from tracker; treating as empty. ${err}` - ); - publishedAtlasesPromise = null; - return []; - }); + publishedAtlasesPromise = loadPublishedAtlases().catch((err) => { + publishedAtlasesPromise = null; + throw err; + }); } return publishedAtlasesPromise; } +/** + * Returns the tracker base URL, read at call time to ensure env vars are loaded. + * @returns tracker base URL. + */ +function getTrackerUrl(): string { + const url = process.env.NEXT_PUBLIC_ATLAS_TRACKER_URL; + if (!url) { + throw new Error("NEXT_PUBLIC_ATLAS_TRACKER_URL is not configured"); + } + return url; +} + /** * Checks whether a tracker atlas is currently published. * @param shortNameSlug - Atlas short name slug (e.g., "gut"). @@ -122,10 +165,107 @@ export async function isTrackerAtlasPublished( shortNameSlug: string, version: string ): Promise { - const atlases = await getPublishedAtlases(); - return atlases.some( - (a) => a.shortNameSlug === shortNameSlug && a.version === version - ); + return (await findPublishedAtlas(shortNameSlug, version)) !== undefined; +} + +/** + * Loads the published atlases. `next build` reads the snapshot that + * `scripts/build.sh` writes before the build: Next runs getStaticPaths and + * getStaticProps across several worker processes, each with its own + * module-level cache, so fetching per worker could let pages disagree on which + * atlases are published if the tracker changed mid-build, shipping tabs that + * 404 (see #3203). A `next build` without the snapshot (e.g. a bare `npx next + * build`) therefore fails. Anything else, such as `next dev`, fetches live, + * even if the snapshot variable leaked into its environment. + * @returns published atlases. + */ +function loadPublishedAtlases(): Promise { + if (process.env.NEXT_PHASE !== PHASE_PRODUCTION_BUILD) { + return fetchPublishedAtlases(); + } + const snapshotPath = process.env[PUBLISHED_ATLASES_SNAPSHOT_ENV]; + if (!snapshotPath) { + return Promise.reject( + publishedAtlasesError( + `${PUBLISHED_ATLASES_SNAPSHOT_ENV} is not set`, + BUILD_HINT + ) + ); + } + return readPublishedAtlasesSnapshot(snapshotPath); +} + +/** + * Validates the published-atlases response. The body must be an array. Items + * missing a string `shortNameSlug` or `version` are skipped with a warning, so + * a malformed record for an atlas this portal does not configure cannot fail + * the build, but a body where every item is malformed fails it. Fields used + * only once an atlas is resolved (e.g. `id`) are checked by + * `resolveTrackerAtlas`. + * @param data - Parsed response body. + * @returns the well-formed items, typed as published atlases. + */ +function parsePublishedAtlases(data: unknown): PublishedAtlas[] { + if (!Array.isArray(data)) { + throw new Error("Tracker /api/published-atlases returned a non-array body"); + } + const atlases = data.filter((item, i) => { + const missingKey = PUBLISHED_ATLAS_MATCH_KEYS.find( + (key) => typeof item?.[key] !== "string" + ); + if (missingKey === undefined) return true; + console.warn( + `[tracker] Skipping /api/published-atlases item ${i}: no string "${missingKey}"` + ); + return false; + }); + if (data.length > 0 && atlases.length === 0) { + throw new Error( + `Tracker /api/published-atlases has no item with string ${PUBLISHED_ATLAS_MATCH_KEYS.join(" and ")} fields` + ); + } + return atlases; +} + +/** + * Builds the error thrown when the published atlases cannot be loaded. The + * reason goes in the message, not `cause`: Next's build workers send only an + * error's name, message and stack back to the main process, so a `cause` would + * never reach the build log. A string reason is used as is; anything else is + * printed with `util.inspect`, which includes the underlying error's stack, + * Node error details (code, address, port), `cause` chain and AggregateError + * `errors`. + * @param reason - The underlying failure, or a description of it. + * @param hint - Optional advice on how to fix it. + * @returns error describing the failure. + */ +function publishedAtlasesError(reason: unknown, hint?: string): Error { + const text = + typeof reason === "string" + ? reason + : inspect(reason, { breakLength: Infinity, depth: 5 }); + const parts = [`[tracker] Published atlases are unavailable: ${text}`, hint]; + return new Error(parts.filter(Boolean).join("\n")); +} + +/** + * Reads this build's published-atlases snapshot. + * @param snapshotPath - Path of the snapshot written by `scripts/build.sh`. + * @returns published atlases. + */ +async function readPublishedAtlasesSnapshot( + snapshotPath: string +): Promise { + try { + const text = await readFile(snapshotPath, "utf8"); + return parsePublishedAtlases(JSON.parse(text)); + } catch (err) { + const missing = (err as NodeJS.ErrnoException).code === "ENOENT"; + throw publishedAtlasesError( + err, + missing ? SNAPSHOT_MISSING_HINT : undefined + ); + } } /** @@ -138,15 +278,17 @@ export async function resolveTrackerAtlas( shortNameSlug: string, version: string ): Promise { - const atlases = await getPublishedAtlases(); - const match = atlases.find( - (a) => a.shortNameSlug === shortNameSlug && a.version === version - ); + const match = await findPublishedAtlas(shortNameSlug, version); if (!match) { throw new Error( `No published atlas found for slug="${shortNameSlug}" version="${version}"` ); } + if (typeof match.id !== "string") { + throw new Error( + `Published atlas slug="${shortNameSlug}" version="${version}" has no string "id"` + ); + } return match; } diff --git a/apis/tracker/constants.ts b/apis/tracker/constants.ts new file mode 100644 index 00000000..c7f7567c --- /dev/null +++ b/apis/tracker/constants.ts @@ -0,0 +1,6 @@ +// Environment variable holding the path of this build's published-atlases +// snapshot. `scripts/build.sh` writes the snapshot once before `next build`, to +// a path unique to the build, and deletes it however the build ends; every +// build worker reads it, so all pages in a build agree on which atlases are +// published (see #3203). +export const PUBLISHED_ATLASES_SNAPSHOT_ENV = "PUBLISHED_ATLASES_SNAPSHOT"; diff --git a/contexts/atlasContext.ts b/contexts/atlasContext.ts index 4a2affc7..6acac060 100644 --- a/contexts/atlasContext.ts +++ b/contexts/atlasContext.ts @@ -1,10 +1,12 @@ import { NETWORKS, NETWORK_ATLAS_CONTENT } from "@/constants/networks"; -import { AtlasContext as AtlasContextType, AtlasModule } from "@/types/network"; +import type { + AtlasContext as AtlasContextType, + AtlasModule, +} from "@/types/network"; import { createContext, useContext } from "react"; +import { DEFAULT_NETWORK } from "./networkContext"; -const DEFAULT_NETWORK = NETWORKS[0]; - -const DEFAULT_ATLAS = DEFAULT_NETWORK.atlases[0]; +const DEFAULT_ATLAS = NETWORKS[0].atlases[0]; export const AtlasContext = createContext({ atlas: DEFAULT_ATLAS, diff --git a/contexts/networkContext.ts b/contexts/networkContext.ts index 2caa8a4a..fb0601b7 100644 --- a/contexts/networkContext.ts +++ b/contexts/networkContext.ts @@ -1,12 +1,18 @@ import { NETWORKS, NETWORK_CONTENT } from "@/constants/networks"; -import { +import type { + Network, NetworkContext as NetworkContextType, NetworkModule, } from "@/types/network"; import { createContext, useContext } from "react"; +// Default network for contexts rendered without a provider. It lists no +// atlases, so such a component cannot list unpublished tracker atlases whose +// pages were never generated (see #3203). +export const DEFAULT_NETWORK: Network = { ...NETWORKS[0], atlases: [] }; + export const NetworkContext = createContext({ - network: NETWORKS[0], + network: DEFAULT_NETWORK, }); export const NetworkProvider = NetworkContext.Provider; diff --git a/hooks/useNetworkList.ts b/hooks/useNetworkList.ts index 3b06d7eb..611fde10 100644 --- a/hooks/useNetworkList.ts +++ b/hooks/useNetworkList.ts @@ -1,8 +1,10 @@ -import { NETWORKS } from "@/constants/networks"; import type { Network } from "@/types/network"; import { createContext, useContext } from "react"; -const NetworkListContext = createContext(NETWORKS); +// Empty by default: pages must provide the publication-gated list from +// `getAvailableNetworks`, so a page that forgets to cannot list unpublished +// tracker atlases linking to pages that were never generated (see #3203). +const NetworkListContext = createContext([]); export const NetworkListProvider = NetworkListContext.Provider; diff --git a/package.json b/package.json index 4581450d..3875e32d 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,9 @@ "private": true, "scripts": { "dev": "./scripts/dev.sh data-portal && ./scripts/set-version.sh dev && next dev --webpack", - "build-dev:data-portal": "./scripts/common-build.sh data-portal dev && ./scripts/set-version.sh && next build --webpack && npm run postbuild", - "build-prod:data-portal": "./scripts/common-build.sh data-portal prod && ./scripts/set-version.sh && next build --webpack && npm run postbuild", - "build": "next build --webpack", + "build-dev:data-portal": "./scripts/common-build.sh data-portal dev && ./scripts/set-version.sh && npm run build", + "build-prod:data-portal": "./scripts/common-build.sh data-portal prod && ./scripts/set-version.sh && npm run build", + "build": "./scripts/build.sh", "start": "npx serve out", "lint": "eslint .", "check-format": "prettier --check .", diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 00000000..e178d40e --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,13 @@ +#!/bin/bash +set -e + +# Snapshot the tracker's published atlases once, to a path unique to this +# build, so every `next build` worker agrees on which atlases are published +# (see #3203). The trap deletes the snapshot however the build ends. +TMP_DIR="${TMPDIR:-/tmp}" +PUBLISHED_ATLASES_SNAPSHOT=$(mktemp "${TMP_DIR%/}/published-atlases.XXXXXX") +export PUBLISHED_ATLASES_SNAPSHOT +trap 'rm -f "$PUBLISHED_ATLASES_SNAPSHOT"' EXIT + +npx --no-install esrun ./scripts/snapshot-published-atlases.ts +npx --no-install next build --webpack "$@" diff --git a/scripts/snapshot-published-atlases.ts b/scripts/snapshot-published-atlases.ts new file mode 100644 index 00000000..f31d6e6e --- /dev/null +++ b/scripts/snapshot-published-atlases.ts @@ -0,0 +1,38 @@ +import { fetchPublishedAtlases } from "@/apis/tracker/api"; +import { PUBLISHED_ATLASES_SNAPSHOT_ENV } from "@/apis/tracker/constants"; +import fsp from "fs/promises"; +import { createRequire } from "module"; +import path from "path"; + +snapshotPublishedAtlases().catch((err) => { + console.error(err); + process.exit(1); +}); + +/** + * Fetches the tracker's published atlases once and writes them to the + * snapshot every `next build` worker reads, so all pages in a build agree on + * which atlases are published (see #3203). Run by `scripts/build.sh`, which + * sets the snapshot path. + */ +async function snapshotPublishedAtlases(): Promise { + const snapshotPath = process.env[PUBLISHED_ATLASES_SNAPSHOT_ENV]; + if (!snapshotPath) { + throw new Error( + `${PUBLISHED_ATLASES_SNAPSHOT_ENV} is not set; run \`npm run build\`, which sets it` + ); + } + // Load `@next/env` through `next`, so env files are read by the exact copy + // `next build` uses, in the same order, rather than a separately pinned one. + const requireFromRoot = createRequire(path.resolve("package.json")); + const requireFromNext = createRequire( + requireFromRoot.resolve("next/package.json") + ); + const { loadEnvConfig } = requireFromNext("@next/env") as { + loadEnvConfig: (dir: string) => unknown; + }; + loadEnvConfig(process.cwd()); + const atlases = await fetchPublishedAtlases(); + await fsp.writeFile(snapshotPath, JSON.stringify(atlases, undefined, 2)); + console.log(`Wrote ${atlases.length} published atlases to ${snapshotPath}`); +} diff --git a/utils/atlasPages.ts b/utils/atlasPages.ts index 376a8a0d..6bf4f60d 100644 --- a/utils/atlasPages.ts +++ b/utils/atlasPages.ts @@ -1,7 +1,6 @@ import { filterProjectId } from "@/apis/azul/hca-dcp/common/filters"; import type { ProjectsResponse } from "@/apis/azul/hca-dcp/common/responses"; import { processEntityValue } from "@/apis/azul/hca-dcp/common/utils"; -import { isTrackerAtlasPublished } from "@/apis/tracker/api"; import { config } from "@/config/config"; import { NETWORKS } from "@/constants/networks"; import type { Atlas, AtlasContext, CXGDataset, Network } from "@/types/network"; @@ -14,6 +13,7 @@ import type { GetStaticPropsResult, } from "next"; import type { ParsedUrlQuery } from "querystring"; +import { getAvailableNetwork, isAtlasPublished } from "./availableNetworks"; import { fetchCXGDatasetsForAtlases, processAtlas, @@ -35,9 +35,7 @@ export interface TrackerDataOptions { } export const getStaticPaths: GetStaticPaths = () => - buildStaticPaths((atlas) => - atlas.tracker ? isPublishedTrackerAtlas(atlas) : true - ); + buildStaticPaths(isAtlasPublished); /** * Static paths for non-tracker atlases only. Tracker-sourced atlases are @@ -53,7 +51,9 @@ export const getNonTrackerStaticPaths: GetStaticPaths = () => * @returns static paths for published tracker atlases. */ export const getTrackerStaticPaths: GetStaticPaths = () => - buildStaticPaths(isPublishedTrackerAtlas); + buildStaticPaths( + async (atlas) => Boolean(atlas.tracker) && isAtlasPublished(atlas) + ); /** * Builds static props for an atlas page, delegating to the tracker builder when @@ -70,10 +70,18 @@ export async function getContentStaticProps( ): Promise> { const { atlas: atlasParam, network: networkParam } = context.params ?? {}; - const network = NETWORKS.find(({ path }) => path === networkParam) as Network; - const atlas = network.atlases.find( + const configuredNetwork = NETWORKS.find( + ({ path }) => path === networkParam + ) as Network; + // Found in the configured network, not the filtered one, so an atlas that + // reads as unpublished here still reaches `resolveTrackerAtlas` and its + // clear error rather than crashing on an undefined atlas. + const atlas = configuredNetwork.atlases.find( ({ path }) => path === atlasParam ) as Atlas; + // Drop unpublished sibling atlases so the page's network agrees with the + // network and home pages. + const network = await getAvailableNetwork(configuredNetwork); // Delegate to tracker path if atlas has tracker config. if (atlas.tracker) { @@ -151,17 +159,6 @@ async function fetchAtlasProjects(atlas: Atlas): Promise { return hits; } -/** - * Returns true when the atlas is tracker-sourced and published in the tracker. - * @param atlas - Atlas to check. - * @returns true if the atlas is a published tracker atlas. - */ -async function isPublishedTrackerAtlas(atlas: Atlas): Promise { - if (!atlas.tracker) return false; - const { shortNameSlug, version } = atlas.tracker; - return isTrackerAtlasPublished(shortNameSlug, version); -} - /** * Sort datasets by cell count, descending. * @param d0 - First dataset to compare. diff --git a/utils/availableNetworks.ts b/utils/availableNetworks.ts index 7916fd22..35a1c420 100644 --- a/utils/availableNetworks.ts +++ b/utils/availableNetworks.ts @@ -1,6 +1,37 @@ import { isTrackerAtlasPublished } from "@/apis/tracker/api"; import { NETWORKS } from "@/constants/networks"; -import type { Network } from "@/types/network"; +import type { Atlas, Network } from "@/types/network"; + +// The publication gate: every build-time decision about whether an atlas is +// shown or gets pages should go through this module, so callers cannot +// disagree on which atlases are published (see #3203). Pages that list atlases +// pass these results to `NetworkListProvider`, whose default is empty so a page +// that skips the gate lists nothing rather than unpublished atlases. + +/** + * Drops tracker atlases that are not currently published in the tracker; + * non-tracker atlases are always kept. + * @param atlases - Atlases to filter. + * @returns the atlases that should be shown, in their original order. + */ +export async function filterPublishedAtlases( + atlases: Atlas[] +): Promise { + const flags = await Promise.all(atlases.map(isAtlasPublished)); + return atlases.filter((_, i) => flags[i]); +} + +/** + * Returns the network with unpublished tracker atlases filtered out. + * @param network - Network to filter. + * @returns network with only available atlases. + */ +export async function getAvailableNetwork(network: Network): Promise { + return { + ...network, + atlases: await filterPublishedAtlases(network.atlases), + }; +} /** * Returns NETWORKS with unpublished tracker atlases filtered out. Used at @@ -10,22 +41,17 @@ import type { Network } from "@/types/network"; * @returns networks with unpublished tracker atlases filtered out. */ export async function getAvailableNetworks(): Promise { - return Promise.all( - NETWORKS.map(async (network) => { - const flags = await Promise.all( - network.atlases.map((a) => - a.tracker - ? isTrackerAtlasPublished( - a.tracker.shortNameSlug, - a.tracker.version - ) - : Promise.resolve(true) - ) - ); - return { - ...network, - atlases: network.atlases.filter((_, i) => flags[i]), - }; - }) - ); + return Promise.all(NETWORKS.map(getAvailableNetwork)); +} + +/** + * Returns true when the atlas should be shown: non-tracker atlases always are, + * tracker atlases only while published in the tracker. + * @param atlas - Atlas to check. + * @returns true if the atlas is published. + */ +export async function isAtlasPublished(atlas: Atlas): Promise { + if (!atlas.tracker) return true; + const { shortNameSlug, version } = atlas.tracker; + return isTrackerAtlasPublished(shortNameSlug, version); } diff --git a/utils/networkPages.ts b/utils/networkPages.ts index ff806fae..14328657 100644 --- a/utils/networkPages.ts +++ b/utils/networkPages.ts @@ -1,6 +1,5 @@ import { fetchTrackerComponentAtlases, - isTrackerAtlasPublished, resolveTrackerAtlasId, } from "@/apis/tracker/api"; import { NETWORKS } from "@/constants/networks"; @@ -10,6 +9,7 @@ import type { GetStaticPropsContext, GetStaticPropsResult, } from "next"; +import { getAvailableNetwork } from "./availableNetworks"; import { fetchCXGDatasetsForAtlases, processNetwork } from "./network"; import { mapTrackerComponentAtlasToIntegratedAtlas } from "./trackerNetwork"; @@ -30,24 +30,16 @@ export async function getContentStaticProps( ): Promise> { const { network: networkParam } = context.params ?? {}; - const network = NETWORKS.find(({ path }) => path === networkParam) as Network; - - // Drop tracker atlases not currently published in the tracker. - const publishedFlags = await Promise.all( - network.atlases.map((a) => - a.tracker - ? isTrackerAtlasPublished(a.tracker.shortNameSlug, a.tracker.version) - : Promise.resolve(true) - ) + const network = await getAvailableNetwork( + NETWORKS.find(({ path }) => path === networkParam) as Network ); - const availableAtlases = network.atlases.filter((_, i) => publishedFlags[i]); // Fetch CELLxGENE datasets for the network atlases. - const cxgDatasets = await fetchCXGDatasetsForAtlases(availableAtlases); + const cxgDatasets = await fetchCXGDatasetsForAtlases(network.atlases); // Populate integrated atlases for tracker-sourced atlases immutably. const atlases = await Promise.all( - availableAtlases.map(async (atlas) => { + network.atlases.map(async (atlas) => { if (!atlas.tracker) return atlas; const { shortNameSlug, version } = atlas.tracker; const atlasId = await resolveTrackerAtlasId(shortNameSlug, version); diff --git a/utils/networkPublicationPages.ts b/utils/networkPublicationPages.ts index c71d5424..430208ee 100644 --- a/utils/networkPublicationPages.ts +++ b/utils/networkPublicationPages.ts @@ -1,7 +1,11 @@ import { NETWORKS } from "@/constants/networks"; -import { Network, NetworkParam } from "@/types/network"; -import { GetStaticPaths, GetStaticProps, GetStaticPropsContext } from "next"; -import { GetStaticPathsResult } from "next/types"; +import type { Network, NetworkParam } from "@/types/network"; +import type { + GetStaticPaths, + GetStaticProps, + GetStaticPropsContext, +} from "next"; +import type { GetStaticPathsResult } from "next/types"; export const getStaticPaths: GetStaticPaths = async () => { return { @@ -17,7 +21,10 @@ export const getStaticProps: GetStaticProps = async ( const network = NETWORKS.find(({ path }) => path === networkParam) as Network; return { props: { - network: network, + // The page renders only network-level fields, never atlases, so the + // atlas list is dropped rather than publication-gated: that keeps the + // page off the tracker and out of its serialized props. + network: { ...network, atlases: [] }, pageTitle: `${network.name} - BICCN Publications`, }, };