diff --git a/scripts/fetch-docs-local.mjs b/scripts/fetch-docs-local.mjs index 1d9ad648c..6c93d6809 100644 --- a/scripts/fetch-docs-local.mjs +++ b/scripts/fetch-docs-local.mjs @@ -72,6 +72,62 @@ async function getHeadings(source) { }) } +export async function parseDocs(fileNames, topicSlugs, topicDirectory) { + const parsedDocs = await Promise.all( + fileNames.map(async docFilename => { + try { + const path = `${topicDirectory}/${docFilename}` + + if(fs.lstatSync(path).isDirectory()) { + const subDocSlugs = fs.readdirSync(path) + + const subDocs = await parseDocs( + subDocSlugs, + topicSlugs.concat(docFilename), + path + ) + const subTopic = { + slug: docFilename, + path: topicSlugs.concat(docFilename).join('/')+'/', + docs: subDocs.filter(Boolean).sort((a, b) => a.order - b.order), + } + return subTopic + } else { + const rawDoc = fs.readFileSync( + path, + 'utf8', + ) + + const parsedDoc = matter(rawDoc) + + const doc = { + content: await serialize(parsedDoc.content, { + mdxOptions: { + remarkPlugins: [remarkGfm], + }, + }), + title: parsedDoc.data.title, + slug: docFilename.replace('.mdx', ''), + path: topicSlugs.join('/')+'/', + label: parsedDoc.data.label, + order: parsedDoc.data.order, + desc: parsedDoc.data.desc || '', + keywords: parsedDoc.data.keywords || '', + headings: await getHeadings(parsedDoc.content), + } + + return doc + } + + } catch (error) { + const msg = error instanceof Error ? error.message : error || 'Unknown error' + console.error(`Error fetching ${docFilename}: ${msg}`) // eslint-disable-line no-console + } + }), + ) + return parsedDocs +} + const fetchDocs = async () => { const topics = await Promise.all( topicOrder.map(async unsanitizedTopicSlug => { @@ -80,41 +136,11 @@ const fetchDocs = async () => { const topicDirectory = path.join(docsDirectory, `./${topicSlug}`) const docSlugs = fs.readdirSync(topicDirectory) - const parsedDocs = await Promise.all( - docSlugs.map(async docFilename => { - try { - const rawDoc = fs.readFileSync( - `${docsDirectory}/${topicSlug.toLowerCase()}/${docFilename}`, - 'utf8', - ) - - const parsedDoc = matter(rawDoc) - - const doc = { - content: await serialize(parsedDoc.content, { - mdxOptions: { - remarkPlugins: [remarkGfm], - }, - }), - title: parsedDoc.data.title, - slug: docFilename.replace('.mdx', ''), - label: parsedDoc.data.label, - order: parsedDoc.data.order, - desc: parsedDoc.data.desc || '', - keywords: parsedDoc.data.keywords || '', - headings: await getHeadings(parsedDoc.content), - } - - return doc - } catch (error) { - const msg = err instanceof Error ? err.message : err || 'Unknown error' - console.error(`Error fetching ${docFilename}: ${msg}`) // eslint-disable-line no-console - } - }), - ) + const parsedDocs = await parseDocs(docSlugs, [topicSlug], `${docsDirectory}/${topicSlug}`) const topic = { slug: unsanitizedTopicSlug, + path: '/', docs: parsedDocs.filter(Boolean).sort((a, b) => a.order - b.order), } diff --git a/scripts/fetch-docs.mjs b/scripts/fetch-docs.mjs index a15bb9037..753b65251 100644 --- a/scripts/fetch-docs.mjs +++ b/scripts/fetch-docs.mjs @@ -35,6 +35,7 @@ function slugify(string) { } const githubAPI = 'https://api.github.com/repos/payloadcms/payload' +const branch = 'main' const topicOrder = [ 'Getting-Started', @@ -78,6 +79,66 @@ async function getHeadings(source) { }) } +export async function parseDocs(docFilenames, topicSlugs, topicURL) { + const parsedDocs = await Promise.all( + docFilenames.map(async docFilename => { + try { + const path = `${topicURL}/${docFilename}` + const isDirectory = docFilename.includes('.md') ? false : true + if(isDirectory) { + const subDocs = await fetch(`${path}?ref=${branch}`, { + headers, + }).then(res => res.json()) + const subDocFilenames = subDocs.map(({ name }) => name) + + const parsedSubDocs = await parseDocs( + subDocFilenames, + topicSlugs.concat(docFilename), + path + ) + + const subTopic = { + slug: docFilename, + path: topicSlugs.concat(docFilename).join('/')+'/', + docs: parsedSubDocs.filter(Boolean).sort((a, b) => a.order - b.order), + } + return subTopic + } else { + const json = await fetch(`${path}?ref=${branch}`, { + headers, + }).then(res => res.json()) + + const parsedDoc = matter(decodeBase64(json.content)) + + + const doc = { + content: await serialize(parsedDoc.content, { + mdxOptions: { + remarkPlugins: [remarkGfm], + }, + }), + title: parsedDoc.data.title, + slug: docFilename.replace('.mdx', ''), + path: topicSlugs.join('/')+'/', + label: parsedDoc.data.label, + order: parsedDoc.data.order, + desc: parsedDoc.data.desc || '', + keywords: parsedDoc.data.keywords || '', + headings: await getHeadings(parsedDoc.content), + } + + return doc + } + + } catch (error) { + const msg = error instanceof Error ? error.message : error || 'Unknown error' + console.error(`Error fetching ${docFilename}: ${msg}`) // eslint-disable-line no-console + } + }), + ) + return parsedDocs +} + const fetchDocs = async () => { if (!process.env.GITHUB_ACCESS_TOKEN) { console.log('No GitHub access token found - skipping docs retrieval') // eslint-disable-line no-console @@ -88,46 +149,18 @@ const fetchDocs = async () => { topicOrder.map(async unsanitizedTopicSlug => { const topicSlug = unsanitizedTopicSlug.toLowerCase() - const docs = await fetch(`${githubAPI}/contents/docs/${topicSlug}`, { + const docs = await fetch(`${githubAPI}/contents/docs/${topicSlug}?ref=${branch}`, { headers, }).then(res => res.json()) - const docFilenames = docs.map(({ name }) => name) - const parsedDocs = await Promise.all( - docFilenames.map(async docFilename => { - try { - const json = await fetch(`${githubAPI}/contents/docs/${topicSlug}/${docFilename}`, { - headers, - }).then(res => res.json()) - - const parsedDoc = matter(decodeBase64(json.content)) - - const doc = { - content: await serialize(parsedDoc.content, { - mdxOptions: { - remarkPlugins: [remarkGfm], - }, - }), - title: parsedDoc.data.title, - slug: docFilename.replace('.mdx', ''), - label: parsedDoc.data.label, - order: parsedDoc.data.order, - desc: parsedDoc.data.desc || '', - keywords: parsedDoc.data.keywords || '', - headings: await getHeadings(parsedDoc.content), - } - - return doc - } catch (err) { - const msg = err instanceof Error ? err.message : err || 'Unknown error' - console.error(`Error fetching ${docFilename}: ${msg}`) // eslint-disable-line no-console - } - }), - ) + const topicURL = `${githubAPI}/contents/docs/${topicSlug}` + + const parsedDocs = await parseDocs(docFilenames, [topicSlug], topicURL) const topic = { slug: unsanitizedTopicSlug, + path: '/', docs: parsedDocs.filter(Boolean).sort((a, b) => a.order - b.order), } @@ -135,6 +168,7 @@ const fetchDocs = async () => { }), ) + const data = JSON.stringify(topics, null, 2) const docsFilename = path.resolve(__dirname, './src/app/docs.json') diff --git a/src/app/(pages)/docs/[topic]/[doc]/client_page.tsx b/src/app/(pages)/docs/[topic]/[...doc]/client_page.tsx similarity index 100% rename from src/app/(pages)/docs/[topic]/[doc]/client_page.tsx rename to src/app/(pages)/docs/[topic]/[...doc]/client_page.tsx diff --git a/src/app/(pages)/docs/[topic]/[doc]/index.module.scss b/src/app/(pages)/docs/[topic]/[...doc]/index.module.scss similarity index 100% rename from src/app/(pages)/docs/[topic]/[doc]/index.module.scss rename to src/app/(pages)/docs/[topic]/[...doc]/index.module.scss diff --git a/src/app/(pages)/docs/[topic]/[doc]/page.tsx b/src/app/(pages)/docs/[topic]/[...doc]/page.tsx similarity index 65% rename from src/app/(pages)/docs/[topic]/[doc]/page.tsx rename to src/app/(pages)/docs/[topic]/[...doc]/page.tsx index 0d890708f..b710f44ab 100644 --- a/src/app/(pages)/docs/[topic]/[doc]/page.tsx +++ b/src/app/(pages)/docs/[topic]/[...doc]/page.tsx @@ -8,8 +8,9 @@ import { NextDoc } from '../../types' import { RenderDoc } from './client_page' const Doc = async ({ params }) => { - const { topic, doc: docSlug } = params - const doc = await getDoc({ topic, doc: docSlug }) + const { topic, doc: docSlugs } = params + const docPathWithSlug = topic + '/' + docSlugs.join('/') + const doc = await getDoc({ topic, doc: docPathWithSlug }) const topics = await getTopics() const relatedThreads = await fetchRelatedThreads() @@ -29,7 +30,9 @@ const Doc = async ({ params }) => { let next: NextDoc | null = null if (parentTopic) { - const docIndex = parentTopic?.docs.findIndex(({ slug }) => slug === docSlug) + const docIndex = parentTopic?.docs.findIndex( + ({ path, slug }) => path + slug === docPathWithSlug, + ) if (parentTopic?.docs?.[docIndex + 1]) { next = { @@ -57,7 +60,7 @@ export default Doc type Param = { topic: string - doc: string + doc: string[] } export async function generateStaticParams() { @@ -65,33 +68,36 @@ export async function generateStaticParams() { const topics = await getTopics() + function extractParams(docs, topicSlug: string, parentSlugs: string[] = []): Param[] { + return docs.flatMap(doc => { + // If doc has subdocs, recursively call extractParams + if (doc.docs && doc.docs.length > 0) { + return extractParams(doc.docs, topicSlug, [...parentSlugs, doc.slug]) + } else if (doc.slug) { + // If there are no subdocs, add the doc (including parent slugs if any) + return [{ topic: topicSlug.toLowerCase(), doc: [...parentSlugs, doc.slug] }] + } + return [] // If doc has no slug, return an empty array to avoid null values + }) + } + const result = topics.reduce((params: Param[], topic) => { - return params.concat( - topic.docs - .map(doc => { - if (!doc.slug) return null as any - - return { - topic: topic.slug.toLowerCase(), - doc: doc.slug, - } - }) - .filter(Boolean), - ) - }, []) + const topicParams = extractParams(topic.docs, topic.slug) + return params.concat(topicParams) + }, [] as Param[]) return result } - -export async function generateMetadata({ params: { topic: topicSlug, doc: docSlug } }) { - const doc = await getDoc({ topic: topicSlug, doc: docSlug }) +export async function generateMetadata({ params: { topic: topicSlug, doc: docSlugs } }) { + const docPathWithSlug = topicSlug + docSlugs.join('/') + const doc = await getDoc({ topic: topicSlug, doc: docPathWithSlug }) return { title: `${doc?.title ? `${doc.title} | ` : ''}Documentation | Payload CMS`, description: doc?.desc || `Payload CMS ${topicSlug} Documentation`, openGraph: mergeOpenGraph({ title: `${doc?.title ? `${doc.title} | ` : ''}Documentation | Payload CMS`, - url: `/docs/${topicSlug}/${docSlug}`, + url: `/docs/${docPathWithSlug}`, images: [ { url: `/api/og?topic=${topicSlug}&title=${doc?.title}`, diff --git a/src/app/(pages)/docs/api.ts b/src/app/(pages)/docs/api.ts index 4baea1b9d..57c4905d9 100644 --- a/src/app/(pages)/docs/api.ts +++ b/src/app/(pages)/docs/api.ts @@ -1,20 +1,50 @@ import content from '../../docs.json' -import type { Doc, DocPath, Topic } from './types' +import type { Doc, DocMeta, DocOrTopic, DocPath, Topic } from './types' export async function getTopics(): Promise { return content.map(topic => ({ slug: topic.slug, + path: topic.path || '/', docs: topic.docs.map(doc => ({ title: doc?.title || '', label: doc?.label || '', slug: doc?.slug || '', order: doc?.order || 0, + docs: ((doc as any)?.docs as DocMeta[]) || null, + path: doc?.path || '/', })), })) } -export async function getDoc({ topic: topicSlug, doc: docSlug }: DocPath): Promise { - const matchedTopic = content.find(topic => topic.slug.toLowerCase() === topicSlug) - const matchedDoc = matchedTopic?.docs?.find(doc => doc?.slug === docSlug) || null - return matchedDoc +export async function getDoc({ + topic: topicSlug, + doc: docPathWithSlug, +}: DocPath): Promise { + // Find the matched topic first + const matchedTopic = content.find(topic => topic.slug.toLowerCase() === topicSlug.toLowerCase()) + + // If there's no matched topic, return null early. + if (!matchedTopic) return null + + // Recursive function to find a doc by slug within a topic or sub-docs + function findDoc(docs: DocOrTopic[], pathAndSlug: string): Doc | null { + for (const doc of docs) { + // Check if the current doc matches the slug + if (doc && (doc.path || '/') + (doc.slug || '/') === pathAndSlug && !('docs' in doc)) { + return doc + } + // If the current doc has subdocs, search within them recursively + if ('docs' in doc && doc?.docs && doc.docs.length > 0) { + const subDoc = findDoc(doc.docs, pathAndSlug.toLowerCase()) + if (subDoc) { + return subDoc + } + } + } + // If no doc matches, return null + return null + } + + // Use the recursive function to find the matched doc or subdoc + return findDoc(matchedTopic.docs || [], docPathWithSlug.toLowerCase()) } diff --git a/src/app/(pages)/docs/client_layout.tsx b/src/app/(pages)/docs/client_layout.tsx index 79d80aa5a..22ae5f881 100644 --- a/src/app/(pages)/docs/client_layout.tsx +++ b/src/app/(pages)/docs/client_layout.tsx @@ -13,16 +13,126 @@ import { DocMeta, Topic } from './types' import classes from './index.module.scss' -const openTopicsLocalStorageKey = 'docs-open-topics' +const openTopicsLocalStorageKey = 'docs-open-topics' as const type Props = { topics: Topic[] children: React.ReactNode } -export const RenderDocs: React.FC = ({ topics, children }) => { - const [topicParam, docParam] = useSelectedLayoutSegments() +type RenderSidebarProps = { + topics: Topic[] + openTopicPreferences?: string[] + setOpenTopicPreferences: (topics: string[]) => void + init?: boolean + nesting: number +} +export const RenderSidebarTopics: React.FC = ({ + topics, + setOpenTopicPreferences, + openTopicPreferences, + init, + nesting, +}) => { const [currentTopicIsOpen, setCurrentTopicIsOpen] = useState(true) + const [...params] = useSelectedLayoutSegments() + const middleParams = params[1].split('/') + + return ( + <> + {topics.map(topic => { + const topicSlug = topic.slug.toLowerCase() + const isCurrentTopic = params[0] === topicSlug || params[1] === topicSlug + const isActive = + openTopicPreferences?.includes(topicSlug) || (isCurrentTopic && currentTopicIsOpen) + + return ( + + + +
    + {topic.docs.map((doc: DocMeta) => { + // Check if doc slug matches, and if topic matches + const isDocActive = + params.join('/') === doc.path + doc.slug && + (middleParams.length >= 2 + ? middleParams[middleParams.length - 2] + : params[0] === topicSlug) + + if ('docs' in doc && doc?.docs) { + return ( + + ) + } + return ( +
  • + + {doc.label} + +
  • + ) + })} +
+
+
+ ) + })} + + ) +} + +export const RenderDocs: React.FC = ({ topics, children }) => { + const [topicParam] = useSelectedLayoutSegments() const [openTopicPreferences, setOpenTopicPreferences] = useState() const [init, setInit] = useState(false) const [navOpen, setNavOpen] = useState(false) @@ -60,80 +170,13 @@ export const RenderDocs: React.FC = ({ topics, children }) => { .filter(Boolean) .join(' ')} > - {topics.map(topic => { - const topicSlug = topic.slug.toLowerCase() - const isCurrentTopic = topicParam === topicSlug - const isActive = - openTopicPreferences?.includes(topicSlug) || (isCurrentTopic && currentTopicIsOpen) - - return ( - - - -
    - {topic.docs.map((doc: DocMeta) => { - const isDocActive = docParam === doc.slug && topicParam === topicSlug - - return ( -
  • - - {doc.label} - -
  • - ) - })} -
-
-
- ) - })} +
{children}
@@ -147,6 +190,7 @@ export const RenderDocs: React.FC = ({ topics, children }) => { {navOpen && }
+ ; ) } diff --git a/src/app/(pages)/docs/types.ts b/src/app/(pages)/docs/types.ts index 65174386b..e15f58290 100644 --- a/src/app/(pages)/docs/types.ts +++ b/src/app/(pages)/docs/types.ts @@ -12,8 +12,25 @@ export interface Doc { desc: string keywords: string headings: Heading[] + slug?: string + path?: string } +export type DocOrTopic = + | Doc + | { + content?: any // eslint-disable-line + order?: number + title?: string + label?: string + desc?: string + keywords?: string + headings?: Heading[] + docs: Doc[] + slug?: string + path?: string + } + export interface NextDoc { slug: string title: string @@ -31,9 +48,14 @@ export interface DocMeta { label: string slug: string order: number + docs?: DocMeta[] + /** Path, not including the slug, ends with "/" **/ + path?: string } export interface Topic { docs: DocMeta[] slug: string + /** Path, not including the slug, ends with "/" **/ + path?: string } diff --git a/src/graphics/ChevronIcon/index.tsx b/src/graphics/ChevronIcon/index.tsx index 553e88b85..890cf8c93 100644 --- a/src/graphics/ChevronIcon/index.tsx +++ b/src/graphics/ChevronIcon/index.tsx @@ -1,6 +1,9 @@ -import React from 'react' +import React, { CSSProperties } from 'react' -export const ChevronIcon: React.FC<{ className?: string }> = ({ className }) => { +export const ChevronIcon: React.FC<{ className?: string; style?: CSSProperties }> = ({ + className, + style, +}) => { return ( = ({ className }) => fill="none" xmlns="http://www.w3.org/2000/svg" className={className} + style={style} >