diff --git a/src/components/Dropdown.tsx b/src/components/Dropdown.tsx index 0a58bbf33..93a8cdd6e 100644 --- a/src/components/Dropdown.tsx +++ b/src/components/Dropdown.tsx @@ -20,6 +20,7 @@ type DropdownContentProps = { className?: string align?: 'start' | 'center' | 'end' sideOffset?: number + portal?: boolean } type DropdownItemProps = { @@ -63,24 +64,29 @@ export function DropdownContent({ className, align = 'end', sideOffset = 6, + portal = true, }: DropdownContentProps) { - return ( - - - {children} - - + const content = ( + + {children} + ) + + if (!portal) { + return content + } + + return {content} } export function DropdownItem({ diff --git a/src/components/SearchModal.tsx b/src/components/SearchModal.tsx index e760bea47..b47dd7e31 100644 --- a/src/components/SearchModal.tsx +++ b/src/components/SearchModal.tsx @@ -12,7 +12,6 @@ import { SearchBox, Snippet, Configure, - useMenu, useInstantSearch, useInfiniteHits, } from 'react-instantsearch' @@ -28,6 +27,7 @@ import { getStoredFrameworkPreference, usePersistFrameworkPreference, } from './FrameworkSelect' +import { shouldPersistFrameworkForHit } from '~/utils/searchRecords' /** * Safely decode HTML entities without using innerHTML. @@ -83,7 +83,10 @@ interface AlgoliaHighlightResult { interface AlgoliaHit extends Record { objectID: string url: string + urlWithAnchor?: string library?: string + framework?: string + routeStyle?: string hierarchy: AlgoliaHierarchy content?: string type?: string @@ -153,6 +156,27 @@ const searchClient = liteClient( 'FQ0DQ6MA3C', '10c34d6a5c89f6048cf644d601e65172', ) +const searchIndexName = 'tanstack-test' + +function buildSearchFilters({ + selectedLibrary, + selectedFramework, +}: { + selectedLibrary: string + selectedFramework: string +}) { + const filterParts: string[] = ['(version:latest OR version:all)'] + + if (selectedLibrary) { + filterParts.push(`library:${selectedLibrary}`) + } + + if (selectedFramework) { + filterParts.push(`(framework:${selectedFramework} OR framework:all)`) + } + + return filterParts.join(' AND ') +} // Context to share filter state between components const SearchFiltersContext = React.createContext<{ @@ -165,13 +189,11 @@ const SearchFiltersContext = React.createContext<{ libraryItems: Array<{ value: string label: string - count: number isRefined: boolean }> frameworkItems: Array<{ value: string label: string - count: number isRefined: boolean }> } | null>(null) @@ -202,19 +224,6 @@ function SearchFiltersProvider({ children }: { children: React.ReactNode }) { const [selectedFramework, setSelectedFramework] = React.useState(getInitialFramework) - // Use useMenu just to get the list of available options and counts - // We do NOT use refine() because facet filters don't support OR logic - // Instead, we build custom filter strings via Configure component - const { items: rawLibraryItems } = useMenu({ - attribute: 'library', - limit: 50, - }) - - const { items: rawFrameworkItems } = useMenu({ - attribute: 'framework', - limit: 50, - }) - // Auto-select based on current page URL const pathname = useRouterState({ select: (state) => state.location.pathname, @@ -253,22 +262,56 @@ function SearchFiltersProvider({ children }: { children: React.ReactNode }) { } }, [pathname, getInitialFramework]) - // Sort items by their defined order and filter out "all" from display - const libraryItems = [...rawLibraryItems] - .filter((item) => item.value !== 'all') - .sort((a, b) => { - const aIndex = libraries.findIndex((l) => l.id === a.value) - const bIndex = libraries.findIndex((l) => l.id === b.value) - return aIndex - bIndex - }) - - const frameworkItems = [...rawFrameworkItems] - .filter((item) => item.value !== 'all') - .sort((a, b) => { - const aIndex = frameworkOptions.findIndex((f) => f.value === a.value) - const bIndex = frameworkOptions.findIndex((f) => f.value === b.value) - return aIndex - bIndex - }) + const searchableLibraries = React.useMemo( + () => + libraries.filter( + (library) => library.visible !== false && library.latestVersion, + ), + [], + ) + + const selectedLibraryInfo = React.useMemo( + () => searchableLibraries.find((library) => library.id === selectedLibrary), + [searchableLibraries, selectedLibrary], + ) + + const availableFrameworkValues = React.useMemo(() => { + if (selectedLibraryInfo) { + return selectedLibraryInfo.frameworks + } + + return Array.from( + new Set(searchableLibraries.flatMap((library) => library.frameworks)), + ) + }, [searchableLibraries, selectedLibraryInfo]) + + React.useEffect(() => { + if (!selectedLibraryInfo || !selectedFramework) { + return + } + + if ( + !selectedLibraryInfo.frameworks.some( + (framework) => framework === selectedFramework, + ) + ) { + setSelectedFramework('') + } + }, [selectedFramework, selectedLibraryInfo]) + + const libraryItems = searchableLibraries.map((library) => ({ + value: library.id, + label: library.id, + isRefined: library.id === selectedLibrary, + })) + + const frameworkItems = frameworkOptions + .filter((framework) => availableFrameworkValues.includes(framework.value)) + .map((framework) => ({ + value: framework.value, + label: framework.label, + isRefined: framework.value === selectedFramework, + })) // Wrapper functions that just update state (no Algolia refine) const selectLibrary = React.useCallback((value: string) => { @@ -297,29 +340,10 @@ function SearchFiltersProvider({ children }: { children: React.ReactNode }) { ) } -// Component that builds dynamic filter strings including "all" library/framework pages +// Component that builds dynamic filter strings for the selected search scope. function DynamicFilters() { const { selectedLibrary, selectedFramework } = useSearchFilters() - // Build filter string - // - Always filter to latest version OR "all" (for core pages) - // - When library selected: include that library OR "all" (for core pages) - // - When framework selected: include that framework OR "all" (for integration pages) - const filterParts: string[] = [] - - // Version filter: include latest OR "all" (core pages) - filterParts.push('(version:latest OR version:all)') - - if (selectedLibrary) { - // Include selected library OR "all" (core pages like /ethos) - filterParts.push(`(library:${selectedLibrary} OR library:all)`) - } - - if (selectedFramework) { - // Include selected framework OR "all" (integration pages, core pages) - filterParts.push(`(framework:${selectedFramework} OR framework:all)`) - } - return ( ) } @@ -363,6 +392,8 @@ const SafeLink = React.forwardRef( ref: React.Ref, ) => { const isInternal = href?.includes('//tanstack.com') + const internalUrl = href?.split('//tanstack.com')[1] + const [internalPath, internalHash] = internalUrl?.split('#') ?? [] if (!isInternal) { return ( @@ -383,7 +414,8 @@ const SafeLink = React.forwardRef( return ( { const { closeSearch } = useSearchContext() + const persistFramework = usePersistFrameworkPreference() + + const handleActivate = () => { + const framework = hit.framework + if ( + framework && + shouldPersistFrameworkForHit({ + url: hit.url, + framework, + routeStyle: hit.routeStyle, + }) + ) { + persistFramework(framework) + } + + closeSearch() + } const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() + e.stopPropagation() const link = e.currentTarget as HTMLAnchorElement link.click() - closeSearch() } } const handleClick = () => { - closeSearch() + handleActivate() } const ref = React.useRef(null!) @@ -435,12 +484,13 @@ const Hit = ({ // Get library and framework info for this hit const hitLibrary = hit.library as string | undefined - const hitFramework = frameworkOptions.find((f) => - hit.url.includes(`/framework/${f.value}`), - ) + const hitFramework = + frameworkOptions.find((f) => f.value === hit.framework) ?? + frameworkOptions.find((f) => hit.url.includes(`/framework/${f.value}`)) const hitLibraryInfo = hitLibrary ? libraries.find((l) => l.id === hitLibrary) : null + const hitUrl = hit.urlWithAnchor ?? hit.url // Build hierarchy prefix based on what's filtered const prefixParts: React.ReactNode[] = [] @@ -451,7 +501,8 @@ const Hit = ({ @@ -488,7 +539,7 @@ const Hit = ({ return (
@@ -587,6 +639,7 @@ function LibraryRefinement() { setSelectedLibrary('')} @@ -608,9 +661,6 @@ function LibraryRefinement() { {item.label.toUpperCase()} - - ({item.count}) - ) })} @@ -664,6 +714,7 @@ function FrameworkRefinement() { handleSelect('')} className="font-bold"> All Frameworks @@ -680,7 +731,6 @@ function FrameworkRefinement() { {fw && {fw.label}} {capitalize(item.label)} - ({item.count}) ) })} @@ -723,8 +773,6 @@ function NoResults({
- ))} + ) + })}
)} diff --git a/src/libraries/libraries.ts b/src/libraries/libraries.ts index a68835646..dbf759cac 100644 --- a/src/libraries/libraries.ts +++ b/src/libraries/libraries.ts @@ -32,7 +32,7 @@ export const query: LibrarySlim = { defaultDocs: 'framework/react/overview', sitemap: { includeLandingPage: true, - includeTopLevelDocsPages: true, + includeDocsPages: true, }, installPath: 'framework/$framework/installation', legacyPackages: ['react-query'], @@ -223,7 +223,7 @@ export const router: LibrarySlim = { docsRoot: 'docs/router', sitemap: { includeLandingPage: true, - includeTopLevelDocsPages: true, + includeDocsPages: true, }, legacyPackages: ['react-location'], hideCodesandboxUrl: true, @@ -292,7 +292,7 @@ export const start: LibrarySlim = { defaultDocs: 'framework/react/overview', sitemap: { includeLandingPage: true, - includeTopLevelDocsPages: true, + includeDocsPages: true, }, installPath: 'framework/$framework/build-from-scratch', embedEditor: 'codesandbox', @@ -338,7 +338,7 @@ export const table: LibrarySlim = { defaultDocs: 'introduction', sitemap: { includeLandingPage: true, - includeTopLevelDocsPages: true, + includeDocsPages: true, }, corePackageName: '@tanstack/table-core', legacyPackages: ['react-table'], @@ -411,7 +411,7 @@ export const form: LibrarySlim = { ogImage: 'https://github.com/tanstack/form/raw/main/media/repo-header.png', sitemap: { includeLandingPage: true, - includeTopLevelDocsPages: true, + includeDocsPages: true, }, } @@ -441,6 +441,10 @@ export const virtual: LibrarySlim = { ogImage: 'https://github.com/tanstack/query/raw/main/media/header.png', defaultDocs: 'introduction', legacyPackages: ['react-virtual'], + sitemap: { + includeLandingPage: true, + includeDocsPages: true, + }, } export const ranger: LibrarySlim = { @@ -455,6 +459,7 @@ export const ranger: LibrarySlim = { borderStyle: 'border-black/50 dark:border-gray-100/50', textStyle: 'text-black dark:text-gray-100', textColor: 'text-black dark:text-gray-100', + badgeTextStyle: 'text-white dark:text-gray-900', colorFrom: 'from-black dark:from-gray-100', colorTo: 'to-gray-600 dark:to-gray-400', accentColorFrom: 'from-blue-500', @@ -470,6 +475,10 @@ export const ranger: LibrarySlim = { availableVersions: ['v0'], scarfId: 'dd278e06-bb3f-420c-85c6-6e42d14d8f61', ogImage: 'https://github.com/tanstack/ranger/raw/main/media/headerv1.png', + sitemap: { + includeLandingPage: true, + includeDocsPages: true, + }, } export const store: LibrarySlim = { @@ -496,6 +505,10 @@ export const store: LibrarySlim = { scarfId: '302d0fef-cb3f-43c6-b45c-f055b9745edb', ogImage: 'https://github.com/tanstack/store/raw/main/media/repo-header.png', defaultDocs: 'overview', + sitemap: { + includeLandingPage: true, + includeDocsPages: true, + }, } export const pacer: LibrarySlim = { @@ -524,6 +537,10 @@ export const pacer: LibrarySlim = { scarfId: '302d0fef-cb3f-43c6-b45c-f055b9745edb', ogImage: 'https://github.com/tanstack/pacer/raw/main/media/repo-header.png', defaultDocs: 'overview', + sitemap: { + includeLandingPage: true, + includeDocsPages: true, + }, } export const hotkeys: LibrarySlim = { @@ -551,6 +568,10 @@ export const hotkeys: LibrarySlim = { availableVersions: ['v0'], ogImage: 'https://github.com/tanstack/hotkeys/raw/main/media/repo-header.png', defaultDocs: 'overview', + sitemap: { + includeLandingPage: true, + includeDocsPages: true, + }, } export const db: LibrarySlim = { @@ -579,6 +600,7 @@ export const db: LibrarySlim = { defaultDocs: 'overview', sitemap: { includeLandingPage: true, + includeDocsPages: true, }, } @@ -606,6 +628,10 @@ export const ai: LibrarySlim = { availableVersions: ['v0'], ogImage: 'https://github.com/tanstack/ai/raw/main/media/repo-header.png', defaultDocs: 'getting-started/overview', + sitemap: { + includeLandingPage: true, + includeDocsPages: true, + }, } export const intent: LibrarySlim = { @@ -631,6 +657,10 @@ export const intent: LibrarySlim = { availableVersions: ['v0'], ogImage: 'https://github.com/tanstack/intent/raw/main/media/repo-header.png', defaultDocs: 'overview', + sitemap: { + includeLandingPage: true, + includeDocsPages: true, + }, } export const config: LibrarySlim = { @@ -646,6 +676,7 @@ export const config: LibrarySlim = { borderStyle: 'border-black/50 dark:border-gray-100/50', textStyle: 'text-black dark:text-gray-100', textColor: 'text-black dark:text-gray-100', + badgeTextStyle: 'text-white dark:text-gray-900', colorFrom: 'from-black dark:from-gray-100', colorTo: 'to-gray-600 dark:to-gray-400', accentColorFrom: 'from-blue-500', @@ -660,6 +691,10 @@ export const config: LibrarySlim = { latestBranch: 'main', availableVersions: ['v0'], ogImage: 'https://github.com/tanstack/config/raw/main/media/repo-header.png', + sitemap: { + includeLandingPage: true, + includeDocsPages: true, + }, } export const devtools: LibrarySlim = { @@ -691,6 +726,10 @@ export const devtools: LibrarySlim = { availableVersions: ['v0'], ogImage: 'https://github.com/tanstack/devtools/raw/main/media/repo-header.png', + sitemap: { + includeLandingPage: true, + includeDocsPages: true, + }, } export const mcp: LibrarySlim = { @@ -751,6 +790,10 @@ export const cli: LibrarySlim = { availableVersions: ['v0'], ogImage: 'https://github.com/tanstack/cli/raw/main/media/repo-header.png', defaultDocs: 'overview', + sitemap: { + includeLandingPage: true, + includeDocsPages: true, + }, } export const libraries: LibrarySlim[] = [ diff --git a/src/libraries/types.ts b/src/libraries/types.ts index 12c9a6f4b..8002e950a 100644 --- a/src/libraries/types.ts +++ b/src/libraries/types.ts @@ -82,7 +82,7 @@ export type LibrarySlim = { visible?: boolean sitemap?: { includeLandingPage?: boolean - includeTopLevelDocsPages?: boolean + includeDocsPages?: boolean } } diff --git a/src/routes/$libraryId/$version.docs.$.tsx b/src/routes/$libraryId/$version.docs.$.tsx index ee525cec5..07b8fa3a7 100644 --- a/src/routes/$libraryId/$version.docs.$.tsx +++ b/src/routes/$libraryId/$version.docs.$.tsx @@ -61,19 +61,28 @@ export const Route = createFileRoute('/$libraryId/$version/docs/$')({ } }, head: ({ loaderData, params }) => { - const { libraryId } = params + const { libraryId, version, _splat: docsPath } = params const library = findLibrary(libraryId) if (!library) { throw notFound() } + const frameworkVariantLinks = (loaderData?.frameworks ?? []).map( + (framework) => ({ + rel: 'alternate', + type: 'text/markdown', + href: `/${libraryId}/${version}/docs/${docsPath}.md?framework=${framework}`, + }), + ) + return { meta: seo({ title: `${loaderData?.title} | ${library.name} Docs`, description: loaderData?.description, noindex: library.visible === false, }), + links: frameworkVariantLinks, } }, component: Docs, diff --git a/src/utils/docs.functions.ts b/src/utils/docs.functions.ts index dbfeea200..2b266c7dd 100644 --- a/src/utils/docs.functions.ts +++ b/src/utils/docs.functions.ts @@ -8,8 +8,10 @@ import { fetchApiContents, fetchRepoFile, isRecoverableGitHubContentError, + shouldUseLocalDocsFiles, } from '~/utils/documents.server' import { renderMarkdownToRsc } from './markdown' +import { extractFrameworksFromMarkdown } from './markdown/filterFrameworkContent' import { getCachedDocsArtifact } from './github-content-cache.server' import { buildRedirectManifest, type RedirectManifestEntry } from './redirects' import { removeLeadingSlash } from './utils' @@ -115,11 +117,79 @@ function isDocsManifest(value: unknown): value is DocsManifest { ) } +async function buildDocsManifest({ + repo, + branch, + docsRoot, +}: { + repo: string + branch: string + docsRoot: string +}): Promise { + const nodes = await fetchApiContents(repo, branch, docsRoot) + + if (!nodes) { + return { paths: [], redirects: {} } + } + + const markdownFiles = flattenDocsNodes(nodes).filter((node) => + node.path.endsWith('.md'), + ) + const paths = new Set() + const redirects: Array = [] + + for (const node of markdownFiles) { + const canonicalPath = getCanonicalDocsPath(node.path, docsRoot) + + if (canonicalPath === null) { + continue + } + + paths.add(canonicalPath) + + const file = await fetchRepoFile(repo, branch, node.path) + + if (!file) { + continue + } + + const frontMatter = extractFrontMatter(file) + + for (const redirectFrom of frontMatter.data.redirectFrom ?? []) { + const normalizedRedirect = normalizeDocsRedirectPath( + redirectFrom, + docsRoot, + ) + + if (!normalizedRedirect || normalizedRedirect === canonicalPath) { + continue + } + + redirects.push({ + from: normalizedRedirect, + to: canonicalPath, + source: node.path, + }) + } + } + + return { + paths: Array.from(paths), + redirects: buildRedirectManifest(redirects, { + label: `docs redirects for ${repo}@${branch}:${docsRoot}`, + }), + } +} + export const fetchDocsManifest = createServerFn({ method: 'GET' }) .inputValidator(docsManifestInput) .handler(async ({ data }) => { const { repo, branch, docsRoot } = data + if (shouldUseLocalDocsFiles()) { + return buildDocsManifest({ repo, branch, docsRoot }) + } + return getCachedDocsArtifact({ repo, gitRef: branch, @@ -127,61 +197,7 @@ export const fetchDocsManifest = createServerFn({ method: 'GET' }) artifactType: 'docs-manifest', artifactKey: 'default', isValue: isDocsManifest, - build: async () => { - const nodes = await fetchApiContents(repo, branch, docsRoot) - - if (!nodes) { - return { paths: [], redirects: {} } - } - - const markdownFiles = flattenDocsNodes(nodes).filter((node) => - node.path.endsWith('.md'), - ) - const paths = new Set() - const redirects: Array = [] - - for (const node of markdownFiles) { - const canonicalPath = getCanonicalDocsPath(node.path, docsRoot) - - if (canonicalPath === null) { - continue - } - - paths.add(canonicalPath) - - const file = await fetchRepoFile(repo, branch, node.path) - - if (!file) { - continue - } - - const frontMatter = extractFrontMatter(file) - - for (const redirectFrom of frontMatter.data.redirectFrom ?? []) { - const normalizedRedirect = normalizeDocsRedirectPath( - redirectFrom, - docsRoot, - ) - - if (!normalizedRedirect || normalizedRedirect === canonicalPath) { - continue - } - - redirects.push({ - from: normalizedRedirect, - to: canonicalPath, - source: node.path, - }) - } - } - - return { - paths: Array.from(paths), - redirects: buildRedirectManifest(redirects, { - label: `docs redirects for ${repo}@${branch}:${docsRoot}`, - }), - } - }, + build: () => buildDocsManifest({ repo, branch, docsRoot }), }) }) @@ -239,6 +255,7 @@ export const fetchDocs = createServerFn({ method: 'GET' }) contentRsc, title: frontMatter.data?.title ?? 'Content temporarily unavailable', description, + frameworks: extractFrameworksFromMarkdown(frontMatter.content), filePath, headings, frontmatter: frontMatter.data, @@ -255,6 +272,7 @@ export const fetchDocsPage = createServerFn({ method: 'GET' }) description: doc.description, filePath: doc.filePath, frontmatter: doc.frontmatter, + frameworks: doc.frameworks, headings: doc.headings, title: doc.title, } diff --git a/src/utils/documents.server.ts b/src/utils/documents.server.ts index 0dff458d7..42833b569 100644 --- a/src/utils/documents.server.ts +++ b/src/utils/documents.server.ts @@ -43,7 +43,7 @@ export function isRecoverableGitHubContentError( ) } -function shouldUseLocalDocsFiles() { +export function shouldUseLocalDocsFiles() { if (process.env.NODE_ENV !== 'development') { return false } diff --git a/src/utils/markdown/filterFrameworkContent.ts b/src/utils/markdown/filterFrameworkContent.ts index 66fa8d724..ba423b6f9 100644 --- a/src/utils/markdown/filterFrameworkContent.ts +++ b/src/utils/markdown/filterFrameworkContent.ts @@ -31,6 +31,53 @@ type FilterOptions = { keepMarkers?: boolean } +export function extractFrameworksFromMarkdown(markdown: string): Array { + const frameworks: Array = [] + const seen = new Set() + + const addFramework = (framework: string) => { + const normalizedFramework = framework.trim().toLowerCase() + if (!normalizedFramework || seen.has(normalizedFramework)) { + return + } + + seen.add(normalizedFramework) + frameworks.push(normalizedFramework) + } + + const frameworkBlockRegex = + /([\s\S]*?)/gi + let frameworkBlockMatch: RegExpExecArray | null + + while ((frameworkBlockMatch = frameworkBlockRegex.exec(markdown)) !== null) { + for (const section of splitByFrameworkHeadings( + frameworkBlockMatch[1] ?? '', + )) { + addFramework(section.framework) + } + } + + const tabsBlockRegex = + /([\s\S]*?)/gi + let tabsBlockMatch: RegExpExecArray | null + + while ((tabsBlockMatch = tabsBlockRegex.exec(markdown)) !== null) { + const attrs = tabsBlockMatch[1] ?? '' + const variant = parseAttribute(attrs, 'variant') + if (variant !== 'package-manager' && variant !== 'package-managers') { + continue + } + + for (const framework of Object.keys( + parseFrameworkLines(tabsBlockMatch[2] ?? ''), + )) { + addFramework(framework) + } + } + + return frameworks +} + /** * Filters framework-specific content and package-manager tabs from raw markdown. * If no framework is specified, returns markdown unchanged. diff --git a/src/utils/searchRecords.ts b/src/utils/searchRecords.ts new file mode 100644 index 000000000..497da1487 --- /dev/null +++ b/src/utils/searchRecords.ts @@ -0,0 +1,43 @@ +export type SearchHitFrameworkContext = { + url: string + framework?: string | null + routeStyle?: string | null +} + +function getPathname(url: string) { + try { + return new URL(url, 'https://tanstack.com').pathname + } catch { + return url.split('#')[0]?.split('?')[0] ?? url + } +} + +export function hasFrameworkPath(url: string) { + const segments = getPathname(url).split('/').filter(Boolean) + + for (let index = 0; index < segments.length - 2; index++) { + if ( + segments[index] === 'docs' && + segments[index + 1] === 'framework' && + segments[index + 2] + ) { + return true + } + } + + return false +} + +export function shouldPersistFrameworkForHit(hit: SearchHitFrameworkContext) { + const framework = hit.framework?.trim().toLowerCase() + + if (!framework || framework === 'all') { + return false + } + + if (hit.routeStyle === 'framework-path') { + return false + } + + return !hasFrameworkPath(hit.url) +} diff --git a/src/utils/sitemap.ts b/src/utils/sitemap.ts index 07ea983f4..6b3be4f82 100644 --- a/src/utils/sitemap.ts +++ b/src/utils/sitemap.ts @@ -9,8 +9,6 @@ export type SitemapEntry = { lastModified?: string } -const MAX_DOCS_SITEMAP_DEPTH = 3 - const HIGH_VALUE_NON_DOC_PAGES = [ '/', '/blog', @@ -22,6 +20,14 @@ const HIGH_VALUE_NON_DOC_PAGES = [ '/paid-support', ] as const satisfies ReadonlyArray +const LOW_VALUE_DOCS_SITEMAP_SEGMENTS = new Set(['examples', 'community']) + +const LOW_VALUE_DOCS_SITEMAP_SLUGS = new Set([ + 'community-resources', + 'contributors', + 'npm-stats', +]) + function trimTrailingSlash(url: string) { return url.replace(/\/$/, '') } @@ -48,16 +54,19 @@ function getLibraryEntries(): Array { ) { return [] } - const basePath = `/${library.id}/latest` return [{ path: basePath }] }) } -function isTopLevelDocsSlug(slug: string) { - const segments = slug.split('/') +function isHighValueDocsSlug(slug: string) { + const segments = slug.split('/').filter(Boolean) - return segments.length <= MAX_DOCS_SITEMAP_DEPTH + return ( + segments.length > 0 && + !segments.some((segment) => LOW_VALUE_DOCS_SITEMAP_SEGMENTS.has(segment)) && + !LOW_VALUE_DOCS_SITEMAP_SLUGS.has(slug) + ) } async function getLibraryDocsEntries( @@ -66,7 +75,7 @@ async function getLibraryDocsEntries( if ( library.visible === false || !library.latestVersion || - library.sitemap?.includeTopLevelDocsPages !== true + library.sitemap?.includeDocsPages !== true ) { return [] } @@ -81,7 +90,7 @@ async function getLibraryDocsEntries( return manifest.paths .filter(Boolean) - .filter(isTopLevelDocsSlug) + .filter(isHighValueDocsSlug) .map((slug) => ({ path: `/${library.id}/latest/docs/${slug}`, })) @@ -108,7 +117,11 @@ export async function getSitemapEntries(): Promise> { ...getLibraryEntries(), ...docsEntries.flat(), ...getBlogEntries(), - ] + ].filter( + (entry) => + entry.path !== '/intent/registry' && + !entry.path.startsWith('/intent/registry/'), + ) return Array.from( new Map(entries.map((entry) => [entry.path, entry])).values(), diff --git a/tests/filter-framework-content.test.ts b/tests/filter-framework-content.test.ts new file mode 100644 index 000000000..e7af89d5d --- /dev/null +++ b/tests/filter-framework-content.test.ts @@ -0,0 +1,71 @@ +import { extractFrameworksFromMarkdown } from '../src/utils/markdown/filterFrameworkContent' + +function assertEqual(actual: unknown, expected: unknown, message: string) { + const actualJson = JSON.stringify(actual) + const expectedJson = JSON.stringify(expected) + if (actualJson !== expectedJson) { + throw new Error(`${message}: expected ${expectedJson}, got ${actualJson}`) + } +} + +const frameworkBlockMarkdown = ` +Shared content. + + + +# React + +React content. + +# Solid + +Solid content. + +# Vue + +Vue content. + + +` + +assertEqual( + extractFrameworksFromMarkdown(frameworkBlockMarkdown), + ['react', 'solid', 'vue'], + 'framework block frameworks extracted', +) + +const packageManagerMarkdown = ` + +react: @tanstack/react-query +solid: @tanstack/solid-query +react: @tanstack/react-query-devtools + +` + +assertEqual( + extractFrameworksFromMarkdown(packageManagerMarkdown), + ['react', 'solid'], + 'package manager frameworks extracted and deduped', +) + +const mixedMarkdown = ` + +# Svelte +Svelte content. +# React +React content. + + + +vue: @tanstack/vue-form +svelte: @tanstack/svelte-form + +` + +assertEqual( + extractFrameworksFromMarkdown(mixedMarkdown), + ['svelte', 'react', 'vue'], + 'mixed framework sources preserve first-seen order', +) + +console.log('filter-framework-content tests passed') diff --git a/tests/search-records.test.ts b/tests/search-records.test.ts new file mode 100644 index 000000000..f25912306 --- /dev/null +++ b/tests/search-records.test.ts @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict' +import { + hasFrameworkPath, + shouldPersistFrameworkForHit, + type SearchHitFrameworkContext, +} from '../src/utils/searchRecords' + +type ShouldPersistCase = { + name: string + hit: SearchHitFrameworkContext + expected: boolean +} + +const shouldPersistCases: Array = [ + { + name: 'canonical framework hit persists framework before navigation', + hit: { + url: 'https://tanstack.com/form/latest/docs/overview#validation', + framework: 'solid', + routeStyle: 'canonical', + }, + expected: true, + }, + { + name: 'all-framework hit does not mutate preference', + hit: { + url: 'https://tanstack.com/form/latest/docs/overview#validation', + framework: 'all', + routeStyle: 'canonical', + }, + expected: false, + }, + { + name: 'framework path hit already carries framework in URL', + hit: { + url: 'https://tanstack.com/query/latest/docs/framework/solid/overview#validation', + framework: 'solid', + }, + expected: false, + }, + { + name: 'framework-path route style does not persist framework', + hit: { + url: 'https://tanstack.com/query/latest/docs/overview#validation', + framework: 'solid', + routeStyle: 'framework-path', + }, + expected: false, + }, +] + +for (const testCase of shouldPersistCases) { + assert.equal( + shouldPersistFrameworkForHit(testCase.hit), + testCase.expected, + testCase.name, + ) +} + +assert.equal( + hasFrameworkPath('/query/latest/docs/framework/solid/overview#validation'), + true, + 'relative framework path is detected', +) + +assert.equal( + hasFrameworkPath('/form/latest/docs/overview#validation'), + false, + 'canonical docs path is not treated as a framework path', +) + +console.log('search-records tests passed')