diff --git a/package-lock.json b/package-lock.json index 80a4e47..c1e5130 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "pako": "^2.1.0", "react": "^18", "react-dom": "^18", + "semver": "^7.5.4", "three": "^0.151.3", "tippy.js": "^6.3.7" }, @@ -24,6 +25,7 @@ "@types/pako": "^2.0.2", "@types/react": "^18", "@types/react-dom": "^18", + "@types/semver": "^7.5.4", "@types/three": "^0.152.1", "autoprefixer": "^10", "cross-env": "^7.0.3", @@ -479,6 +481,12 @@ "integrity": "sha512-s/FPdYRmZR8SjLWGMCuax7r3qCWQw9QKHzXVukAuuIJkXkDRwp+Pu5LMIVFi0Fxbav35WURicYr8u1QsoybnQw==", "dev": true }, + "node_modules/@types/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-MMzuxN3GdFwskAnb6fz0orFvhfqi752yjaXylr0Rp4oDg5H0Zn1IuyRhDVvYOwAXoJirx2xuS16I3WjxnAIHiQ==", + "dev": true + }, "node_modules/@types/stats.js": { "version": "0.17.2", "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.2.tgz", @@ -3344,7 +3352,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, "dependencies": { "yallist": "^4.0.0" }, @@ -8373,7 +8380,6 @@ "version": "7.5.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dev": true, "dependencies": { "lru-cache": "^6.0.0" }, @@ -9428,8 +9434,7 @@ "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, "node_modules/yocto-queue": { "version": "0.1.0", diff --git a/package.json b/package.json index bb0e6e9..75654db 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "pako": "^2.1.0", "react": "^18", "react-dom": "^18", + "semver": "^7.5.4", "three": "^0.151.3", "tippy.js": "^6.3.7" }, @@ -27,6 +28,7 @@ "@types/pako": "^2.0.2", "@types/react": "^18", "@types/react-dom": "^18", + "@types/semver": "^7.5.4", "@types/three": "^0.152.1", "autoprefixer": "^10", "cross-env": "^7.0.3", diff --git a/scripts/download_data.mjs b/scripts/download_data.mjs index 7fcadc0..c3adb95 100644 --- a/scripts/download_data.mjs +++ b/scripts/download_data.mjs @@ -53,7 +53,6 @@ async function downloadVersion(versionInfo) { // Extract all pages: const guideData = JSON.parse(unzippedGuideData.toString()); - const pages = Object.keys(guideData.pages); // URL which assets are relative to const baseUrl = new URL(".", guideDataUrl); @@ -68,7 +67,6 @@ async function downloadVersion(versionInfo) { development, slug: versionSlug, dataFilename, - pages, defaultNamespace: guideData.defaultNamespace, }; } @@ -91,39 +89,7 @@ for (let i = 0; i < results.length; i++) { } } -// Build a global page list -const versionInfoList = []; -const pagePaths = []; -for (let { pages, ...versionInfo } of downloadedVersions) { - versionInfoList.push(versionInfo); - - // Uses next.js type paths: Array - const versionSlug = versionInfo.slug; - for (let pageId of pages) { - // Manipulate the page path - const [namespace, markdownPath] = pageId.split(":"); - const pathSegments = []; - if (namespace !== versionInfo.defaultNamespace) { - pathSegments.push(namespace); - } - pathSegments.push(...markdownPath.split("/")); - pathSegments[pathSegments.length - 1] = pathSegments[ - pathSegments.length - 1 - ].replace(/\.md$/i, ""); - - pagePaths.push({ - versionSlug, - pagePath: pathSegments, - }); - } -} - await writeFile( path.join(dataFolder, "index.json"), - JSON.stringify(versionInfoList, null, 2), -); - -await writeFile( - path.join(dataFolder, "page_paths.json"), - JSON.stringify(pagePaths, null, 2), + JSON.stringify(downloadedVersions, null, 2), ); diff --git a/src/app/[versionSlug]/[...pagePath]/page.tsx b/src/app/[versionSlug]/[...pagePath]/page.tsx index bbb456c..ac966c6 100644 --- a/src/app/[versionSlug]/[...pagePath]/page.tsx +++ b/src/app/[versionSlug]/[...pagePath]/page.tsx @@ -1,13 +1,42 @@ -import pagePaths from "../../../../data/page_paths.json"; -import { getGuide, getPageIdFromSlugs } from "../../../build-data"; +import { + getGuide, + getPageIdFromSlugs, + getPagePath, + getSlugsFromPageId, +} from "../../../build-data"; import { compilePage } from "@component/page-compiler/compilePage"; -import { ReactElement } from "react"; import { Metadata } from "next"; -import GuidePageTitle from "@component/nav/GuidePageTitle.tsx"; // Return a list of `params` to populate the [slug] dynamic segment +import GuidePageTitle from "@component/nav/GuidePageTitle.tsx"; +import { ReactElement } from "react"; // Return a list of `params` to populate the [slug] dynamic segment +import { redirect } from "next/navigation"; +import { Guide, NavigationNode } from "../../../build-data/Guide.ts"; // Entry-point for the entire version Slug + +// Entry-point for the entire version Slug +function isIndexPage(pagePath: string[]) { + return pagePath.length === 1 && pagePath[0] === "index"; +} // Return a list of `params` to populate the [slug] dynamic segment -export async function generateStaticParams() { - return pagePaths; +export async function generateStaticParams({ params: { versionSlug } }: any) { + const guide = await getGuide(versionSlug); + + let hadIndexPage = false; + const pages = Object.keys(guide.index.pages).map((pageId) => { + const [_, pagePath] = getSlugsFromPageId(guide, pageId); + if (isIndexPage(pagePath)) { + hadIndexPage = true; + } + return { pagePath }; + }); + + // Ensure an index page is present. This will be auto-generated on-demand if needed. + if (!hadIndexPage) { + pages.push({ + pagePath: ["index"], + }); + } + + return pages; } function getTextContent(elem: ReactElement | string): string { @@ -27,33 +56,72 @@ function getTextContent(elem: ReactElement | string): string { } } +function shouldRedirectToIndexPage(guide: Guide, pagePath: string[]) { + return ( + isIndexPage(pagePath) && + !guide.pageExists(guide.defaultNamespace + ":index.md") + ); +} + export async function generateMetadata({ params: { pagePath, versionSlug }, }: any): Promise { const guide = await getGuide(versionSlug); + if (shouldRedirectToIndexPage(guide, pagePath)) { + return {}; + } const pageId = await getPageIdFromSlugs(versionSlug, pagePath); + if (!guide.pageExists(pageId)) { + } const page = guide.getPage(pageId); const { title } = compilePage(guide, pageId, page); if (title) { + const titleText = getTextContent(title); return { - title: - getTextContent(title) + " - AE2 Players Guide for " + guide.gameVersion, + title: titleText + " - AE2 Players Guide for " + guide.gameVersion, }; } else { return {}; } } +function findFirstPageNavigationNode( + nodes: NavigationNode[], +): string | undefined { + for (const node of nodes) { + if (node.hasPage) { + return node.pageId; + } + const firstPageId = findFirstPageNavigationNode(node.children); + if (firstPageId) { + return firstPageId; + } + } + return undefined; +} + export default async function Page({ params: { pagePath, versionSlug } }: any) { const guide = await getGuide(versionSlug); + + if (shouldRedirectToIndexPage(guide, pagePath)) { + // Pick the first navigation nodes target + const firstPageId = findFirstPageNavigationNode( + guide.index.navigationRootNodes, + ); + if (!firstPageId) { + throw new Error("Couldn't find a suitable index navigation node"); + } + redirect(getPagePath(guide, firstPageId)); + } + const pageId = await getPageIdFromSlugs(versionSlug, pagePath); const page = guide.getPage(pageId); const { title, content } = compilePage(guide, pageId, page); return ( <> {content} - {title && } + {title && } ); } diff --git a/src/app/[versionSlug]/layout.tsx b/src/app/[versionSlug]/layout.tsx index 014f143..3ae9725 100644 --- a/src/app/[versionSlug]/layout.tsx +++ b/src/app/[versionSlug]/layout.tsx @@ -3,7 +3,14 @@ import { NavBarNode } from "@component/nav/GuideNavBar.tsx"; import GuideShell from "@component/nav/GuideShell.tsx"; import { getGuide, getPagePath } from "../../build-data"; import { Guide, NavigationNode } from "../../build-data/Guide.ts"; +import { guideVersions } from "../../build-data/GuideVersionIndex.ts"; +// Return a list of `params` to populate the [slug] dynamic segment +export function generateStaticParams() { + return guideVersions.map((version) => ({ + versionSlug: version.slug, + })); +} function buildNavigationNode(guide: Guide, node: NavigationNode): NavBarNode { let href: string | undefined; let icon: string | undefined; diff --git a/src/app/page.tsx b/src/app/page.tsx index 3149c60..a40c60c 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -26,9 +26,11 @@ function GuideVersionSelection() { return (

Guide Versions

- {guideVersions.reverse().map((version) => ( - - ))} + {guideVersions + .filter((gv) => !gv.development) + .map((version) => ( + + ))}
); } diff --git a/src/build-data/GuideVersionIndex.ts b/src/build-data/GuideVersionIndex.ts index 073c50b..f9caca4 100644 --- a/src/build-data/GuideVersionIndex.ts +++ b/src/build-data/GuideVersionIndex.ts @@ -1,4 +1,5 @@ import data from "../../data/index.json"; +import { coerce, compare } from "semver"; export type GuideVersion = { baseUrl: string; @@ -15,6 +16,16 @@ export type GuideVersionIndex = GuideVersion[]; export const guideVersions: GuideVersionIndex = data; +function compareMinecraftVersion(a: string, b: string) { + const aVersion = coerce(a) ?? "0.0.0"; + const bVersion = coerce(b) ?? "0.0.0"; + return compare(aVersion, bVersion); +} + +guideVersions.sort((a, b) => + compareMinecraftVersion(b.gameVersion, a.gameVersion), +); + /** * Find the Version that uses the given path-segment. */ diff --git a/src/components/nav/GuidePageTitle.tsx b/src/components/nav/GuidePageTitle.tsx index 94407a5..bf6fb8a 100644 --- a/src/components/nav/GuidePageTitle.tsx +++ b/src/components/nav/GuidePageTitle.tsx @@ -1,17 +1,17 @@ "use client"; -import { useEffect } from "react"; +import { ReactElement, useEffect } from "react"; import { useGuidePageTitleSetter } from "@component/nav/GuidePageTitleProvider.tsx"; export interface GuidePageTitleProps { - title: string; + title: ReactElement | undefined; } function GuidePageTitle({ title }: GuidePageTitleProps) { const setPageTitle = useGuidePageTitleSetter(); useEffect(() => { - setPageTitle(title); + setPageTitle(title ?? null); return () => { - setPageTitle(""); + setPageTitle(null); }; }, [setPageTitle, title]); return null; diff --git a/src/components/nav/GuidePageTitleProvider.tsx b/src/components/nav/GuidePageTitleProvider.tsx index 1bdadc7..c074fd9 100644 --- a/src/components/nav/GuidePageTitleProvider.tsx +++ b/src/components/nav/GuidePageTitleProvider.tsx @@ -1,8 +1,8 @@ "use client"; -import { createContext, useContext } from "react"; +import { createContext, ReactElement, useContext } from "react"; -type GuidePageTitleSetter = (title: string) => void; +type GuidePageTitleSetter = (title: ReactElement | null) => void; const context = createContext(() => {}); diff --git a/src/components/nav/GuideShell.tsx b/src/components/nav/GuideShell.tsx index 25965b1..47bdaae 100644 --- a/src/components/nav/GuideShell.tsx +++ b/src/components/nav/GuideShell.tsx @@ -1,6 +1,11 @@ "use client"; -import React, { PropsWithChildren, useCallback, useState } from "react"; +import React, { + PropsWithChildren, + ReactElement, + useCallback, + useState, +} from "react"; import css from "../../app/[versionSlug]/layout.module.css"; import Link from "next/link"; import Image from "next/image"; @@ -18,7 +23,7 @@ function GuideShell({ gameVersion, navigationNodes, }: PropsWithChildren) { - const [pageTitle, setPageTitle] = useState(""); + const [pageTitle, setPageTitle] = useState(null); const [menuExpanded, setMenuExpanded] = useState(false); const toggleMenu = useCallback(() => { setMenuExpanded((expanded) => !expanded);