diff --git a/src/__tests__/proxy.test.ts b/src/__tests__/proxy.test.ts new file mode 100644 index 0000000..fe5883c --- /dev/null +++ b/src/__tests__/proxy.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const intlHandler = vi.fn(() => 'intl-result') + +vi.mock('next-intl/middleware', () => ({ + default: vi.fn(() => intlHandler), +})) + +const redirect = vi.fn((url: { pathname: string }, status: number) => ({ + kind: 'redirect', + pathname: url.pathname, + status, +})) + +vi.mock('next/server', () => ({ + NextResponse: { redirect }, + NextRequest: class {}, +})) + +const { proxy, config } = await import('../proxy') + +type FakeUrl = { pathname: string; clone: () => FakeUrl } + +function makeRequest(pathname: string) { + const url: FakeUrl = { + pathname, + clone() { + return makeRequest(this.pathname).nextUrl + }, + } + return { nextUrl: url } as never +} + +beforeEach(() => { + redirect.mockClear() + intlHandler.mockClear() +}) + +describe('proxy', () => { + it('redirects two-letter locale docs URLs to the non-localized path with 307', () => { + const result = proxy(makeRequest('/es/docs/getting-started')) as { + pathname: string + status: number + } + + expect(redirect).toHaveBeenCalledTimes(1) + expect(result.pathname).toBe('/docs/getting-started') + expect(result.status).toBe(307) + }) + + it('redirects region-qualified locales like /pt-BR/docs/*', () => { + const result = proxy(makeRequest('/pt-BR/docs/hive/setup')) as { pathname: string } + + expect(result.pathname).toBe('/docs/hive/setup') + }) + + it('redirects the /SC/docs/* pseudo-locale', () => { + const result = proxy(makeRequest('/SC/docs/hive')) as { pathname: string } + + expect(result.pathname).toBe('/docs/hive') + }) + + it('redirects the root path to /docs with 307', () => { + const result = proxy(makeRequest('/')) as { pathname: string; status: number } + + expect(result.pathname).toBe('/docs') + expect(result.status).toBe(307) + }) + + it('delegates non-docs, non-root paths to the i18n middleware', () => { + const request = makeRequest('/en/community') + const result = proxy(request) + + expect(result).toBe('intl-result') + expect(intlHandler).toHaveBeenCalledWith(request) + expect(redirect).not.toHaveBeenCalled() + }) + + it('does not treat non-locale prefixes as localized docs URLs', () => { + // three-letter prefix does not match the locale pattern + const result = proxy(makeRequest('/abc/docs/page')) + + expect(result).toBe('intl-result') + expect(redirect).not.toHaveBeenCalled() + }) + + it('exports a matcher config that excludes docs, api, and static assets', () => { + expect(config.matcher).toContain('/') + expect(config.matcher.some((m: string) => m.includes('?!docs|api'))).toBe(true) + }) +}) diff --git a/src/__tests__/robots.test.ts b/src/__tests__/robots.test.ts new file mode 100644 index 0000000..322ed14 --- /dev/null +++ b/src/__tests__/robots.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' + +import robots from '../app/robots' + +describe('robots', () => { + it('allows all user agents on the whole site', () => { + const result = robots() + + expect(result.rules).toEqual({ userAgent: '*', allow: '/' }) + }) + + it('points crawlers at the canonical sitemap URL', () => { + expect(robots().sitemap).toBe('https://docs.hivecommons.dev/sitemap.xml') + }) +}) diff --git a/src/__tests__/sitemap.test.ts b/src/__tests__/sitemap.test.ts new file mode 100644 index 0000000..1c11279 --- /dev/null +++ b/src/__tests__/sitemap.test.ts @@ -0,0 +1,133 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import fs from 'fs' +import os from 'os' +import path from 'path' + +import sitemap from '../app/sitemap' + +const SITE_URL = 'https://docs.hivecommons.dev' + +let fixtureRoot: string + +function write(relPath: string, content = '# stub\n') { + const full = path.join(fixtureRoot, relPath) + fs.mkdirSync(path.dirname(full), { recursive: true }) + fs.writeFileSync(full, content) +} + +beforeAll(() => { + fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'sitemap-fixture-')) + + // hive project content + write('docs/content/hive/index.mdx') + write('docs/content/hive/guides/setup.md') + write('docs/content/hive/guides/advanced/scaling.mdx') + // excluded: underscore-prefixed file and directory + write('docs/content/hive/_partial.md') + write('docs/content/hive/_drafts/wip.md') + // excluded: hidden dir, node_modules, common-subs, images + write('docs/content/hive/.hidden/secret.md') + write('docs/content/hive/node_modules/pkg/readme.md') + write('docs/content/hive/common-subs/shared.md') + write('docs/content/hive/images/pic.md') + // excluded: non-markdown file + write('docs/content/hive/diagram.svg') + + // pluk project with an index file inside a subdirectory + write('docs/content/pluk/cli/index.md') + + vi.spyOn(process, 'cwd').mockReturnValue(fixtureRoot) +}) + +afterAll(() => { + vi.restoreAllMocks() + fs.rmSync(fixtureRoot, { recursive: true, force: true }) +}) + +describe('sitemap', () => { + it('always includes homepage, locale marketing page, and docs landing page', () => { + const entries = sitemap() + const urls = entries.map((e) => e.url) + + expect(urls).toContain(SITE_URL) + expect(urls).toContain(`${SITE_URL}/en`) + expect(urls).toContain(`${SITE_URL}/docs`) + }) + + it('assigns the documented priorities to each page tier', () => { + const entries = sitemap() + const byUrl = new Map(entries.map((e) => [e.url, e])) + + expect(byUrl.get(SITE_URL)?.priority).toBe(1.0) + expect(byUrl.get(`${SITE_URL}/en`)?.priority).toBe(0.8) + expect(byUrl.get(`${SITE_URL}/docs`)?.priority).toBe(0.9) + expect(byUrl.get(`${SITE_URL}/docs/hive`)?.priority).toBe(0.9) + expect(byUrl.get(`${SITE_URL}/docs/hive/guides/setup`)?.priority).toBe(0.7) + }) + + it('emits a project root entry for every project even without content', () => { + const entries = sitemap() + const urls = entries.map((e) => e.url) + + for (const base of ['hive', 'hotshot', 'pluk', 'rationguard', 'promptargs', 'dibs', 'spektacular']) { + expect(urls).toContain(`${SITE_URL}/docs/${base}`) + } + }) + + it('maps markdown files to routes, stripping extensions and /index suffixes', () => { + const entries = sitemap() + const urls = entries.map((e) => e.url) + + expect(urls).toContain(`${SITE_URL}/docs/hive/guides/setup`) + expect(urls).toContain(`${SITE_URL}/docs/hive/guides/advanced/scaling`) + // cli/index.md collapses to the parent folder route + expect(urls).toContain(`${SITE_URL}/docs/pluk/cli`) + expect(urls).not.toContain(`${SITE_URL}/docs/pluk/cli/index`) + }) + + it('excludes underscore, hidden, node_modules, common-subs, images, and non-markdown entries', () => { + const entries = sitemap() + const urls = entries.map((e) => e.url) + + expect(urls.some((u) => u.includes('_partial'))).toBe(false) + expect(urls.some((u) => u.includes('_drafts'))).toBe(false) + expect(urls.some((u) => u.includes('.hidden') || u.includes('secret'))).toBe(false) + expect(urls.some((u) => u.includes('node_modules'))).toBe(false) + expect(urls.some((u) => u.includes('common-subs') || u.includes('shared'))).toBe(false) + expect(urls.some((u) => u.includes('/images/') || u.includes('pic'))).toBe(false) + expect(urls.some((u) => u.includes('diagram'))).toBe(false) + }) + + it('uses file mtime as lastModified for docs pages', () => { + const setupPath = path.join(fixtureRoot, 'docs/content/hive/guides/setup.md') + const mtime = fs.statSync(setupPath).mtime + + const entries = sitemap() + const entry = entries.find((e) => e.url === `${SITE_URL}/docs/hive/guides/setup`) + + expect(entry?.lastModified).toEqual(mtime) + }) + + it('marks docs pages weekly and marketing pages monthly', () => { + const entries = sitemap() + const byUrl = new Map(entries.map((e) => [e.url, e])) + + expect(byUrl.get(SITE_URL)?.changeFrequency).toBe('monthly') + expect(byUrl.get(`${SITE_URL}/en`)?.changeFrequency).toBe('monthly') + expect(byUrl.get(`${SITE_URL}/docs`)?.changeFrequency).toBe('weekly') + expect(byUrl.get(`${SITE_URL}/docs/hive/guides/setup`)?.changeFrequency).toBe('weekly') + }) + + it('returns no docs-page entries when the content root is missing', () => { + const emptyRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'sitemap-empty-')) + const spy = vi.spyOn(process, 'cwd').mockReturnValue(emptyRoot) + try { + const entries = sitemap() + // homepage + /en + /docs + 7 project roots, nothing else + expect(entries).toHaveLength(10) + } finally { + spy.mockReturnValue(fixtureRoot) + fs.rmSync(emptyRoot, { recursive: true, force: true }) + } + }) +})