From 653bd6cb17a5a1b8c6ab7e422b0ed95ea4e139a4 Mon Sep 17 00:00:00 2001 From: Franklin Koch Date: Thu, 24 Oct 2024 12:58:25 -0600 Subject: [PATCH] =?UTF-8?q?=F0=9F=97=82=20Support=20url=20slugs=20with=20m?= =?UTF-8?q?ultiple=20path=20segments=20(#489)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rowan Cockett --- .changeset/early-plums-double.md | 8 ++ package-lock.json | 88 +++++++++---------- packages/common/package.json | 6 +- packages/common/src/utils.ts | 4 +- packages/jupyter/package.json | 8 +- packages/myst-demo/package.json | 8 +- packages/myst-to-react/package.json | 4 +- packages/providers/package.json | 6 +- packages/site/package.json | 6 +- .../src/components/Navigation/Navigation.tsx | 2 +- .../Navigation/TableOfContentsItems.tsx | 10 ++- packages/site/src/seo/sitemap.ts | 3 +- themes/article/app/routes/$.tsx | 11 +-- ...x => ($a).($b).($c).($d).$slug[.json].tsx} | 10 ++- themes/article/app/utils/loaders.server.ts | 17 +++- themes/article/package.json | 4 +- themes/article/template.yml | 3 + themes/book/app/routes/$.tsx | 9 +- ...ct)_.($a).($b).($c).($d).$slug[.json].tsx} | 17 ++-- themes/book/app/utils/loaders.server.ts | 19 ++-- themes/book/package.json | 4 +- themes/book/template.yml | 3 + 22 files changed, 150 insertions(+), 100 deletions(-) create mode 100644 .changeset/early-plums-double.md rename themes/article/app/routes/{$slug[.json].tsx => ($a).($b).($c).($d).$slug[.json].tsx} (79%) rename themes/book/app/routes/{($project)_.$slug[.json].tsx => ($project)_.($a).($b).($c).($d).$slug[.json].tsx} (73%) diff --git a/.changeset/early-plums-double.md b/.changeset/early-plums-double.md new file mode 100644 index 000000000..c8e9bce90 --- /dev/null +++ b/.changeset/early-plums-double.md @@ -0,0 +1,8 @@ +--- +'@myst-theme/common': patch +'@myst-theme/article': patch +'@myst-theme/site': patch +'@myst-theme/book': patch +--- + +Support url slugs with multiple path segments diff --git a/package-lock.json b/package-lock.json index 39c876beb..64e1e5a52 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31182,13 +31182,13 @@ } }, "node_modules/myst-common": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/myst-common/-/myst-common-1.7.2.tgz", - "integrity": "sha512-xWYABtbIHNrX087EmwyuoSwhl9oJQfQ+3+6H+lv+bfLWRE1DZO4lr03113rjE4/I1IvjZgX3rE4Nb+SLF83DFQ==", + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/myst-common/-/myst-common-1.7.3.tgz", + "integrity": "sha512-eHZ1iHOk+Agob5s+Oni7DVkicBCaEtNE1tQPkI4XYP8hZPPkE5F/qUmaL9ijw3z9y3KPs/XBT2dPSLYRwU80hA==", "license": "MIT", "dependencies": { "mdast": "^3.0.0", - "myst-frontmatter": "^1.7.2", + "myst-frontmatter": "^1.7.3", "myst-spec": "^0.0.5", "nanoid": "^4.0.0", "unified": "^10.1.2", @@ -31215,13 +31215,13 @@ } }, "node_modules/myst-config": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/myst-config/-/myst-config-1.7.2.tgz", - "integrity": "sha512-JuV54yVuMfgYGGFihNc/WDZ180smNer4wuw3jk7osmcow/CsldCWwXKNB44AO/Ii96aVLZCyPPm2CyhlE4W5pw==", + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/myst-config/-/myst-config-1.7.3.tgz", + "integrity": "sha512-f8l6vejVWSxJp1jeTz9ni/AiaEQz8n+fAIxfVrEUjlKYp9f+Y90DdUYsBgbUu0R2LJJ76Bt/WUnRh3NZFR461Q==", "license": "MIT", "dependencies": { - "myst-common": "^1.7.2", - "myst-frontmatter": "^1.7.2", + "myst-common": "^1.7.3", + "myst-frontmatter": "^1.7.3", "simple-validators": "^1.1.0" } }, @@ -31310,9 +31310,9 @@ } }, "node_modules/myst-frontmatter": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/myst-frontmatter/-/myst-frontmatter-1.7.2.tgz", - "integrity": "sha512-J2kvADyifsGkawRL8M96i3MIxCu3Z6JliBNYsEEfFGEJRGIzMtNAyJlLlu836QqQoBVV7WaRY5Dc6aYgSxqPUw==", + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/myst-frontmatter/-/myst-frontmatter-1.7.3.tgz", + "integrity": "sha512-abgMy6COBGXZwcNKPXPUAWQp34WLDFWLiZJDV3CzNamiGHa/r2ymuGRmV3yLBWc1NACYwn2RQ+zEOaX3tP2WaA==", "license": "MIT", "dependencies": { "credit-roles": "^2.1.0", @@ -31383,9 +31383,9 @@ "license": "MIT" }, "node_modules/myst-spec-ext": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/myst-spec-ext/-/myst-spec-ext-1.7.2.tgz", - "integrity": "sha512-oYDMHicKW3tm16OwnsTTiAo0hYMXBK1v8MDFZyHzavb2Q8artsqyAl/O2N0cefM5zI8L2JdMHhewd9tcx9iwvA==", + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/myst-spec-ext/-/myst-spec-ext-1.7.3.tgz", + "integrity": "sha512-YOqwnSTImGnIEFk/g8NWmMWiJh96Xp17V+0vNCuVleGxDd7HI588FcnptLYCTp+3SENzIRVBztS8JjHxNi+V4Q==", "license": "MIT", "dependencies": { "myst-spec": "^0.0.5" @@ -31566,14 +31566,14 @@ } }, "node_modules/myst-to-typst": { - "version": "0.0.24", - "resolved": "https://registry.npmjs.org/myst-to-typst/-/myst-to-typst-0.0.24.tgz", - "integrity": "sha512-d13fSRBGk+kdEaF5tX0T/RsmdGPkO1hSxcX+v5vvRE3wvhhSFVpD8tACxK+IHhZC7XrUUR+RZ7/F6KXPvpoIyw==", + "version": "0.0.25", + "resolved": "https://registry.npmjs.org/myst-to-typst/-/myst-to-typst-0.0.25.tgz", + "integrity": "sha512-akTsKaqFrsmiCLQOyxzeJYyUvXkDX2Y2T4Ir7gXIW8Cl/sjX9Jo807yNE8VV5LjFZzJHc597gCErQXSLOnCXZw==", "license": "MIT", "dependencies": { - "myst-common": "^1.7.2", - "myst-frontmatter": "^1.7.2", - "myst-spec-ext": "^1.7.2", + "myst-common": "^1.7.3", + "myst-frontmatter": "^1.7.3", + "myst-spec-ext": "^1.7.3", "tex-to-typst": "^0.0.7", "unist-util-select": "^4.0.3", "vfile-reporter": "^7.0.4" @@ -40971,9 +40971,9 @@ "version": "0.13.2", "license": "MIT", "dependencies": { - "myst-common": "^1.7.2", - "myst-config": "^1.7.2", - "myst-spec-ext": "^1.7.2", + "myst-common": "^1.7.3", + "myst-config": "^1.7.3", + "myst-spec-ext": "^1.7.3", "nbtx": "^0.2.3", "unist-util-select": "^4.0.3" } @@ -41062,11 +41062,11 @@ "buffer": "^6.0.3", "classnames": "^2.5.1", "jupyterlab-plotly": "^5.24.0", - "myst-common": "^1.7.2", - "myst-config": "^1.7.2", - "myst-frontmatter": "^1.7.2", + "myst-common": "^1.7.3", + "myst-config": "^1.7.3", + "myst-frontmatter": "^1.7.3", "myst-spec": "^0.0.5", - "myst-spec-ext": "^1.7.2", + "myst-spec-ext": "^1.7.3", "myst-to-react": "^0.13.2", "nanoid": "^4.0.2", "nbtx": "^0.2.3", @@ -41118,15 +41118,15 @@ "@heroicons/react": "^2.0.18", "classnames": "^2.3.2", "js-yaml": "^4.1.0", - "myst-common": "^1.7.2", - "myst-config": "^1.7.2", + "myst-common": "^1.7.3", + "myst-config": "^1.7.3", "myst-directives": "^1.5.7", "myst-ext-card": "^1.0.9", "myst-ext-exercise": "^1.0.8", "myst-ext-grid": "^1.0.8", "myst-ext-proof": "^1.0.11", "myst-ext-tabs": "^1.0.8", - "myst-frontmatter": "^1.7.2", + "myst-frontmatter": "^1.7.3", "myst-parser": "^1.5.7", "myst-spec": "^0.0.5", "myst-to-docx": "^1.0.12", @@ -41134,7 +41134,7 @@ "myst-to-jats": "^1.0.30", "myst-to-react": "^0.13.2", "myst-to-tex": "^1.0.38", - "myst-to-typst": "^0.0.24", + "myst-to-typst": "^0.0.25", "myst-transforms": "^1.3.26", "unified": "^10.1.2", "unist-util-remove": "^4.0.0", @@ -41183,8 +41183,8 @@ "@scienceicons/react": "^0.0.11", "buffer": "^6.0.3", "classnames": "^2.3.2", - "myst-common": "^1.7.2", - "myst-config": "^1.7.2", + "myst-common": "^1.7.3", + "myst-config": "^1.7.3", "myst-spec": "^0.0.5", "nanoid": "^4.0.2", "react-syntax-highlighter": "15.5.0", @@ -41243,9 +41243,9 @@ "peerDependencies": { "@types/react": "^16.8 || ^17.0 || ^18.0", "@types/react-dom": "^16.8 || ^17.0 || ^18.0", - "myst-common": "^1.7.2", - "myst-config": "^1.7.2", - "myst-frontmatter": "^1.7.2", + "myst-common": "^1.7.3", + "myst-config": "^1.7.3", + "myst-frontmatter": "^1.7.3", "react": "^16.8 || ^17.0 || ^18.0", "react-dom": "^16.8 || ^17.0 || ^18.0" } @@ -41285,10 +41285,10 @@ "@radix-ui/react-visually-hidden": "^1.1.0", "classnames": "^2.3.2", "lodash.throttle": "^4.1.1", - "myst-common": "^1.7.2", - "myst-config": "^1.7.2", + "myst-common": "^1.7.3", + "myst-config": "^1.7.3", "myst-demo": "^0.13.2", - "myst-spec-ext": "^1.7.2", + "myst-spec-ext": "^1.7.3", "myst-to-react": "^0.13.2", "nbtx": "^0.2.3", "node-cache": "^5.1.2", @@ -41349,8 +41349,8 @@ "@remix-run/node": "~1.17.0", "@remix-run/react": "~1.17.0", "@remix-run/vercel": "~1.17.0", - "myst-common": "^1.7.2", - "myst-config": "^1.7.2", + "myst-common": "^1.7.3", + "myst-config": "^1.7.3", "node-fetch": "^2.6.11", "react": "^18.2.0", "react-dom": "^18.2.0" @@ -41382,8 +41382,8 @@ "@remix-run/node": "~1.17.0", "@remix-run/react": "~1.17.0", "@remix-run/vercel": "~1.17.0", - "myst-common": "^1.7.2", - "myst-config": "^1.7.2", + "myst-common": "^1.7.3", + "myst-config": "^1.7.3", "node-fetch": "^2.6.11", "react": "^18.2.0", "react-dom": "^18.2.0" diff --git a/packages/common/package.json b/packages/common/package.json index aba63e4d8..6d41674ce 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -19,9 +19,9 @@ "build": "npm-run-all -l clean -p build:esm" }, "dependencies": { - "myst-common": "^1.7.2", - "myst-config": "^1.7.2", - "myst-spec-ext": "^1.7.2", + "myst-common": "^1.7.3", + "myst-config": "^1.7.3", + "myst-spec-ext": "^1.7.3", "nbtx": "^0.2.3", "unist-util-select": "^4.0.3" } diff --git a/packages/common/src/utils.ts b/packages/common/src/utils.ts index c2bf336c4..6eea17a11 100644 --- a/packages/common/src/utils.ts +++ b/packages/common/src/utils.ts @@ -4,6 +4,7 @@ import type { SiteManifest } from 'myst-config'; import { selectAll } from 'unist-util-select'; import type { Image as ImageSpec, Link as LinkSpec } from 'myst-spec'; import type { FooterLinks, Heading, NavigationLink, PageLoader } from './types.js'; +import { slugToUrl } from 'myst-common'; type Image = ImageSpec & { urlOptimized?: string }; type Link = LinkSpec & { static?: boolean }; @@ -40,9 +41,10 @@ export function getProjectHeadings( }, ...project.pages.map((p) => { if (!('slug' in p)) return p; + const slug = slugToUrl(p.slug); return { ...p, - path: projectSlug && project.slug ? `/${project.slug}/${p.slug}` : `/${p.slug}`, + path: projectSlug && project.slug ? `/${project.slug}/${slug}` : `/${slug}`, }; }), ]; diff --git a/packages/jupyter/package.json b/packages/jupyter/package.json index 24e8dfedb..22a05788d 100644 --- a/packages/jupyter/package.json +++ b/packages/jupyter/package.json @@ -30,11 +30,11 @@ "buffer": "^6.0.3", "classnames": "^2.5.1", "jupyterlab-plotly": "^5.24.0", - "myst-common": "^1.7.2", - "myst-config": "^1.7.2", - "myst-frontmatter": "^1.7.2", + "myst-common": "^1.7.3", + "myst-config": "^1.7.3", + "myst-frontmatter": "^1.7.3", "myst-spec": "^0.0.5", - "myst-spec-ext": "^1.7.2", + "myst-spec-ext": "^1.7.3", "myst-to-react": "^0.13.2", "nanoid": "^4.0.2", "nbtx": "^0.2.3", diff --git a/packages/myst-demo/package.json b/packages/myst-demo/package.json index ef1c8fee7..51ef71c5e 100644 --- a/packages/myst-demo/package.json +++ b/packages/myst-demo/package.json @@ -23,15 +23,15 @@ "@heroicons/react": "^2.0.18", "classnames": "^2.3.2", "js-yaml": "^4.1.0", - "myst-common": "^1.7.2", - "myst-config": "^1.7.2", + "myst-common": "^1.7.3", + "myst-config": "^1.7.3", "myst-directives": "^1.5.7", "myst-ext-card": "^1.0.9", "myst-ext-exercise": "^1.0.8", "myst-ext-grid": "^1.0.8", "myst-ext-proof": "^1.0.11", "myst-ext-tabs": "^1.0.8", - "myst-frontmatter": "^1.7.2", + "myst-frontmatter": "^1.7.3", "myst-parser": "^1.5.7", "myst-spec": "^0.0.5", "myst-to-docx": "^1.0.12", @@ -39,7 +39,7 @@ "myst-to-jats": "^1.0.30", "myst-to-react": "^0.13.2", "myst-to-tex": "^1.0.38", - "myst-to-typst": "^0.0.24", + "myst-to-typst": "^0.0.25", "myst-transforms": "^1.3.26", "unified": "^10.1.2", "unist-util-remove": "^4.0.0", diff --git a/packages/myst-to-react/package.json b/packages/myst-to-react/package.json index 48ae84374..880af1897 100644 --- a/packages/myst-to-react/package.json +++ b/packages/myst-to-react/package.json @@ -26,8 +26,8 @@ "@radix-ui/react-hover-card": "^1.0.6", "buffer": "^6.0.3", "classnames": "^2.3.2", - "myst-common": "^1.7.2", - "myst-config": "^1.7.2", + "myst-common": "^1.7.3", + "myst-config": "^1.7.3", "myst-spec": "^0.0.5", "nanoid": "^4.0.2", "react-syntax-highlighter": "15.5.0", diff --git a/packages/providers/package.json b/packages/providers/package.json index 3fd379e47..6089b466f 100644 --- a/packages/providers/package.json +++ b/packages/providers/package.json @@ -27,9 +27,9 @@ "peerDependencies": { "@types/react": "^16.8 || ^17.0 || ^18.0", "@types/react-dom": "^16.8 || ^17.0 || ^18.0", - "myst-common": "^1.7.2", - "myst-config": "^1.7.2", - "myst-frontmatter": "^1.7.2", + "myst-common": "^1.7.3", + "myst-config": "^1.7.3", + "myst-frontmatter": "^1.7.3", "react": "^16.8 || ^17.0 || ^18.0", "react-dom": "^16.8 || ^17.0 || ^18.0" }, diff --git a/packages/site/package.json b/packages/site/package.json index 33dcb88dd..be272bbcf 100644 --- a/packages/site/package.json +++ b/packages/site/package.json @@ -35,10 +35,10 @@ "@radix-ui/react-visually-hidden": "^1.1.0", "classnames": "^2.3.2", "lodash.throttle": "^4.1.1", - "myst-common": "^1.7.2", - "myst-config": "^1.7.2", + "myst-common": "^1.7.3", + "myst-config": "^1.7.3", "myst-demo": "^0.13.2", - "myst-spec-ext": "^1.7.2", + "myst-spec-ext": "^1.7.3", "myst-to-react": "^0.13.2", "nbtx": "^0.2.3", "node-cache": "^5.1.2", diff --git a/packages/site/src/components/Navigation/Navigation.tsx b/packages/site/src/components/Navigation/Navigation.tsx index ded97ff03..925f3c42f 100644 --- a/packages/site/src/components/Navigation/Navigation.tsx +++ b/packages/site/src/components/Navigation/Navigation.tsx @@ -77,7 +77,7 @@ export const ConfigurablePrimaryNavigation = ({ if (children) console.warn( - `Including children in Navigation can break keyboard accessbility and is deprecated. Please move children to the page component.`, + `Including children in Navigation can break keyboard accessibility and is deprecated. Please move children to the page component.`, ); // the logic on the following line looks wrong, this will return `null` or `<>` diff --git a/packages/site/src/components/Navigation/TableOfContentsItems.tsx b/packages/site/src/components/Navigation/TableOfContentsItems.tsx index c193e4af6..7e317ee30 100644 --- a/packages/site/src/components/Navigation/TableOfContentsItems.tsx +++ b/packages/site/src/components/Navigation/TableOfContentsItems.tsx @@ -35,10 +35,16 @@ function nestToc(toc: Heading[]): NestedHeading[] { return items; } +function pathnameMatchesHeading(pathname: string, heading: Heading, baseurl?: string) { + const headingPath = withBaseurl(heading.path, baseurl); + if (pathname && headingPath === `${pathname}/index`) return true; + return headingPath === pathname; +} + function childrenOpen(headings: NestedHeading[], pathname: string, baseurl?: string): string[] { return headings .map((heading) => { - if (withBaseurl(heading.path, baseurl) === pathname) return [heading.id]; + if (pathnameMatchesHeading(pathname, heading, baseurl)) return [heading.id]; const open = childrenOpen(heading.children, pathname, baseurl); if (open.length === 0) return []; return [heading.id, ...open]; @@ -110,7 +116,7 @@ const NestedToc = ({ heading }: { heading: NestedHeading }) => { useEffect(() => { if (nav.state === 'idle') setOpen(startOpen); }, [nav.state]); - const exact = pathname === withBaseurl(heading.path, baseurl); + const exact = pathnameMatchesHeading(pathname, heading, baseurl); if (!heading.children || heading.children.length === 0) { return ( ['projects'][0]['pages'][0]; @@ -148,7 +149,7 @@ export function getSiteSlugs( const projectSlug = project.slug ? `/${project.slug}` : ''; const pages = project.pages .filter((page): page is ManifestProjectItem => 'slug' in page) - .map((page) => `${baseurl}${projectSlug}/${page.slug}`); + .map((page) => `${baseurl}${projectSlug}/${slugToUrl(page.slug)}`); if (opts?.excludeIndex) return [...pages]; return [ opts?.explicitIndex diff --git a/themes/article/app/routes/$.tsx b/themes/article/app/routes/$.tsx index 50b2bd20f..4d289abdb 100644 --- a/themes/article/app/routes/$.tsx +++ b/themes/article/app/routes/$.tsx @@ -47,14 +47,15 @@ export const meta: V2_MetaFunction = ({ data, matches, location } export const links: LinksFunction = () => [KatexCSS]; export const loader: LoaderFunction = async ({ params, request }) => { - const [first, second] = new URL(request.url).pathname.slice(1).split('/'); - const projectName = second ? first : undefined; - const slug = second || first; + const [first, ...rest] = new URL(request.url).pathname.slice(1).split('/'); const config = await getConfig(); - const project = getProject(config, projectName ?? slug); + const project = getProject(config, first); + const projectName = project?.slug === first ? first : undefined; + const slugParts = projectName ? rest : [first, ...rest]; + const slug = slugParts.length ? slugParts.join('.') : undefined; const flat = isFlatSite(config); const page = await getPage(request, { - project: flat ? projectName : projectName ?? slug, + project: flat ? projectName : (projectName ?? slug), slug: flat ? slug : projectName ? slug : undefined, redirect: process.env.MODE === 'static' ? false : true, }); diff --git a/themes/article/app/routes/$slug[.json].tsx b/themes/article/app/routes/($a).($b).($c).($d).$slug[.json].tsx similarity index 79% rename from themes/article/app/routes/$slug[.json].tsx rename to themes/article/app/routes/($a).($b).($c).($d).$slug[.json].tsx index 52ae076b3..87c272eec 100644 --- a/themes/article/app/routes/$slug[.json].tsx +++ b/themes/article/app/routes/($a).($b).($c).($d).$slug[.json].tsx @@ -13,9 +13,12 @@ function api404(message = 'No API route found at this URL') { } export const loader: LoaderFunction = async ({ request, params }) => { - const { slug } = params; + const [first, ...rest] = new URL(request.url).pathname + .slice(1) + .replace(/\.json$/, '') + .split('/'); // Handle /myst.xref.json as slug - if (slug === 'myst.xref') { + if (rest.length === 0 && first === 'myst.xref') { const xref = await getMystXrefJson(); if (!xref) { return json({ message: 'myst.xref.json not found', status: 404 }, { status: 404 }); @@ -23,13 +26,14 @@ export const loader: LoaderFunction = async ({ request, params }) => { return json(xref); } // Handle /myst.search.json as slug - else if (slug === 'myst.search') { + else if (rest.length === 0 && first === 'myst.search') { const search = await getMystSearchJson(); if (!search) { return json({ message: 'myst.search.json not found', status: 404 }, { status: 404 }); } return json(search); } + const slug = [first, ...rest].join('.'); const data = await getPage(request, { slug }).catch(() => null); if (!data) return api404('No page found at this URL.'); return json(data, { diff --git a/themes/article/app/utils/loaders.server.ts b/themes/article/app/utils/loaders.server.ts index e6bc21570..8050f3641 100644 --- a/themes/article/app/utils/loaders.server.ts +++ b/themes/article/app/utils/loaders.server.ts @@ -62,11 +62,20 @@ export async function getPage( const project = getProject(config, projectName); if (!project) throw responseNoArticle(); if (opts.slug === project.index && opts.redirect) { - return redirect(projectName ? `/${projectName}` : '/'); + throw redirect(projectName ? `/${projectName}` : '/'); + } + if (opts.slug?.endsWith('.index') && opts.redirect) { + const newSlug = opts.slug.slice(0, -6); + throw redirect(projectName ? `/${projectName}/${newSlug}` : `/${newSlug}`); + } + let slug = opts.loadIndexPage || opts.slug == null ? project.index : opts.slug; + let loader = await getStaticContent(projectName, slug).catch(() => null); + if (!loader) { + // If you haven't loaded the first time, try the `.index` + slug = `${slug}.index`; + loader = await getStaticContent(projectName, slug).catch(() => null); + if (!loader) throw responseNoArticle(); } - const slug = opts.loadIndexPage || opts.slug == null ? project.index : opts.slug; - const loader = await getStaticContent(projectName, slug).catch(() => null); - if (!loader) throw responseNoArticle(); const footer = getFooterLinks(config, projectName, slug); return { ...loader, footer, domain: getDomainFromRequest(request), project: projectName }; } diff --git a/themes/article/package.json b/themes/article/package.json index 7b1cb2303..5ac2349ff 100644 --- a/themes/article/package.json +++ b/themes/article/package.json @@ -25,8 +25,8 @@ "@remix-run/node": "~1.17.0", "@remix-run/react": "~1.17.0", "@remix-run/vercel": "~1.17.0", - "myst-common": "^1.7.2", - "myst-config": "^1.7.2", + "myst-common": "^1.7.3", + "myst-config": "^1.7.3", "node-fetch": "^2.6.11", "react": "^18.2.0", "react-dom": "^18.2.0" diff --git a/themes/article/template.yml b/themes/article/template.yml index d53d5e2dd..9c4d59678 100644 --- a/themes/article/template.yml +++ b/themes/article/template.yml @@ -52,6 +52,9 @@ options: - type: boolean id: numbered_references description: Show references as numbered, rather than in APA-style. Only applies to parenthetical citations. + - type: boolean + id: folders + description: Respect nested folder structure in URL paths. build: install: npm install start: npm run start diff --git a/themes/book/app/routes/$.tsx b/themes/book/app/routes/$.tsx index def9465df..9639893c2 100644 --- a/themes/book/app/routes/$.tsx +++ b/themes/book/app/routes/$.tsx @@ -59,11 +59,12 @@ export const meta: V2_MetaFunction = ({ data, matches, location } export const links: LinksFunction = () => [KatexCSS]; export const loader: LoaderFunction = async ({ params, request }) => { - const [first, second] = new URL(request.url).pathname.slice(1).split('/'); - const projectName = second ? first : undefined; - const slug = second || first; + const [first, ...rest] = new URL(request.url).pathname.slice(1).split('/'); const config = await getConfig(); - const project = getProject(config, projectName ?? slug); + const project = getProject(config, first); + const projectName = project?.slug === first ? first : undefined; + const slugParts = projectName ? rest : [first, ...rest]; + const slug = slugParts.length ? slugParts.join('.') : undefined; const flat = isFlatSite(config); const page = await getPage(request, { project: flat ? projectName : (projectName ?? slug), diff --git a/themes/book/app/routes/($project)_.$slug[.json].tsx b/themes/book/app/routes/($project)_.($a).($b).($c).($d).$slug[.json].tsx similarity index 73% rename from themes/book/app/routes/($project)_.$slug[.json].tsx rename to themes/book/app/routes/($project)_.($a).($b).($c).($d).$slug[.json].tsx index 438aaca31..1dbfacdf0 100644 --- a/themes/book/app/routes/($project)_.$slug[.json].tsx +++ b/themes/book/app/routes/($project)_.($a).($b).($c).($d).$slug[.json].tsx @@ -14,9 +14,12 @@ function api404(message = 'No API route found at this URL') { } export const loader: LoaderFunction = async ({ request, params }) => { - const { project, slug } = params; + const [first, ...rest] = new URL(request.url).pathname + .slice(1) + .replace(/\.json$/, '') + .split('/'); // Handle /myst.xref.json as slug - if (project === undefined && slug === 'myst.xref') { + if (rest.length === 0 && first === 'myst.xref') { const xref = await getMystXrefJson(); if (!xref) { return json({ message: 'myst.xref.json not found', status: 404 }, { status: 404 }); @@ -24,7 +27,7 @@ export const loader: LoaderFunction = async ({ request, params }) => { return json(xref); } // Handle /myst.search.json as slug - else if (slug === 'myst.search') { + else if (rest.length === 0 && first === 'myst.search') { const search = await getMystSearchJson(); if (!search) { return json({ message: 'myst.search.json not found', status: 404 }, { status: 404 }); @@ -33,10 +36,10 @@ export const loader: LoaderFunction = async ({ request, params }) => { } const config = await getConfig(); const flat = isFlatSite(config); - const data = await getPage(request, { - project: flat ? project : (project ?? slug), - slug: flat ? slug : project ? slug : undefined, - }); + const project = flat ? undefined : first; + const slugParts = flat ? [first, ...rest] : rest; + const slug = slugParts.join('.'); + const data = await getPage(request, { project, slug }); if (!data) return api404('No page found at this URL.'); return json(data, { headers: { diff --git a/themes/book/app/utils/loaders.server.ts b/themes/book/app/utils/loaders.server.ts index e92b7b28d..daef03457 100644 --- a/themes/book/app/utils/loaders.server.ts +++ b/themes/book/app/utils/loaders.server.ts @@ -9,7 +9,8 @@ import { } from '@myst-theme/common'; import { redirect } from '@remix-run/node'; import { responseNoArticle, responseNoSite, getDomainFromRequest } from '@myst-theme/site'; -import type { MystSearchIndex } from '@myst-theme/search'; +import type { MystSearchIndex } from '@myst-theme/search'; +import { slugToUrl } from 'myst-common'; const CONTENT_CDN_PORT = process.env.CONTENT_CDN_PORT ?? '3100'; const CONTENT_CDN = process.env.CONTENT_CDN ?? `http://localhost:${CONTENT_CDN_PORT}`; @@ -63,11 +64,19 @@ export async function getPage( const project = getProject(config, projectName); if (!project) throw responseNoArticle(); if (opts.slug === project.index && opts.redirect) { - return redirect(projectName ? `/${projectName}` : '/'); + throw redirect(projectName ? `/${projectName}` : '/'); + } + if (opts.slug?.endsWith('.index') && opts.redirect) { + const newSlug = slugToUrl(opts.slug); + throw redirect(projectName ? `/${projectName}/${newSlug}` : `/${newSlug}`); + } + let slug = opts.loadIndexPage || opts.slug == null ? project.index : opts.slug; + let loader = await getStaticContent(projectName, slug).catch(() => null); + if (!loader) { + slug = `${slug}.index`; + loader = await getStaticContent(projectName, slug).catch(() => null); + if (!loader) throw responseNoArticle(); } - const slug = opts.loadIndexPage || opts.slug == null ? project.index : opts.slug; - const loader = await getStaticContent(projectName, slug).catch(() => null); - if (!loader) throw responseNoArticle(); const footer = getFooterLinks(config, projectName, slug); return { ...loader, footer, domain: getDomainFromRequest(request), project: projectName }; } diff --git a/themes/book/package.json b/themes/book/package.json index 2d240df21..b629a5b05 100644 --- a/themes/book/package.json +++ b/themes/book/package.json @@ -25,8 +25,8 @@ "@remix-run/node": "~1.17.0", "@remix-run/react": "~1.17.0", "@remix-run/vercel": "~1.17.0", - "myst-common": "^1.7.2", - "myst-config": "^1.7.2", + "myst-common": "^1.7.3", + "myst-config": "^1.7.3", "node-fetch": "^2.6.11", "react": "^18.2.0", "react-dom": "^18.2.0" diff --git a/themes/book/template.yml b/themes/book/template.yml index c24d135e9..db30fcb8a 100644 --- a/themes/book/template.yml +++ b/themes/book/template.yml @@ -58,6 +58,9 @@ options: - type: boolean id: numbered_references description: Show references as numbered, rather than in APA-style. Only applies to parenthetical citations. + - type: boolean + id: folders + description: Respect nested folder structure in URL paths. build: install: npm install start: npm run start