Skip to content

Commit 653dd70

Browse files
committed
feat: support robots
1 parent b741c86 commit 653dd70

7 files changed

Lines changed: 375 additions & 11 deletions

File tree

packages/markopress/src/config/validation.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,11 +140,23 @@ const SitemapConfigSchema = z.object({
140140
transformItems: z.any().optional(),
141141
}).passthrough();
142142

143+
/**
144+
* Robots configuration schema
145+
*/
146+
const RobotsConfigSchema = z.object({
147+
userAgent: z.union([z.string(), z.array(z.string())]).optional(),
148+
allow: z.array(z.string()).optional(),
149+
disallow: z.array(z.string()).optional(),
150+
crawlDelay: z.number().nonnegative().optional(),
151+
sitemap: z.string().optional(),
152+
}).passthrough();
153+
143154
/**
144155
* SEO plugin configuration schema
145156
*/
146157
const SeoConfigSchema = z.object({
147158
sitemap: SitemapConfigSchema.optional(),
159+
robots: RobotsConfigSchema.optional(),
148160
}).passthrough().optional();
149161

150162
/**

packages/markopress/src/plugins/seo/index.test.ts

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, it, expect, vi } from 'vitest';
22
import { seoPlugin } from './index.js';
3-
import type { BuildContext } from '../../plugin/types.js';
3+
import * as robotsModule from './robots.js';
4+
import * as sitemapModule from './sitemap.js';
45

56
describe('seoPlugin', () => {
67
it('should have correct plugin name', () => {
@@ -14,22 +15,30 @@ describe('seoPlugin', () => {
1415
expect(typeof plugin.postBuild).toBe('function');
1516
});
1617

17-
it('should skip sitemap if not configured', async () => {
18+
it('should skip when not configured', async () => {
1819
const plugin = seoPlugin();
1920
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
21+
const sitemapSpy = vi.spyOn(sitemapModule, 'generateSitemap').mockResolvedValue(undefined);
22+
const robotsSpy = vi.spyOn(robotsModule, 'generateRobots').mockResolvedValue(undefined);
2023

2124
const mockContext = {
2225
config: {},
2326
} as any;
2427

2528
await plugin.postBuild!(mockContext as any);
2629

27-
expect(consoleSpy).toHaveBeenCalledWith('[seo] Sitemap not configured, skipping');
30+
expect(consoleSpy).toHaveBeenCalledWith('[seo] No SEO generation configured, skipping');
31+
expect(sitemapSpy).not.toHaveBeenCalled();
32+
expect(robotsSpy).not.toHaveBeenCalled();
2833

2934
consoleSpy.mockRestore();
35+
sitemapSpy.mockRestore();
36+
robotsSpy.mockRestore();
3037
});
3138

3239
it('should call generateSitemap when configured', async () => {
40+
const sitemapSpy = vi.spyOn(sitemapModule, 'generateSitemap').mockResolvedValue(undefined);
41+
3342
const plugin = seoPlugin({
3443
sitemap: { hostname: 'https://example.com' },
3544
});
@@ -47,5 +56,38 @@ describe('seoPlugin', () => {
4756

4857
// Should not throw
4958
await expect(plugin.postBuild!(mockContext as any)).resolves.toBeUndefined();
59+
expect(sitemapSpy).toHaveBeenCalledWith(mockContext, { hostname: 'https://example.com' });
60+
61+
sitemapSpy.mockRestore();
62+
});
63+
64+
it('should call generateRobots when configured', async () => {
65+
const robotsSpy = vi.spyOn(robotsModule, 'generateRobots').mockResolvedValue(undefined);
66+
67+
const plugin = seoPlugin({
68+
robots: { disallow: ['/admin'] },
69+
});
70+
71+
const mockContext = {
72+
config: {
73+
seo: {
74+
robots: {
75+
disallow: ['/admin'],
76+
},
77+
},
78+
},
79+
outDir: '/tmp/dist',
80+
routes: {},
81+
allContent: {},
82+
} as any;
83+
84+
await expect(plugin.postBuild!(mockContext as any)).resolves.toBeUndefined();
85+
expect(robotsSpy).toHaveBeenCalledWith(
86+
mockContext,
87+
{ disallow: ['/admin'] },
88+
undefined
89+
);
90+
91+
robotsSpy.mockRestore();
5092
});
5193
});

packages/markopress/src/plugins/seo/index.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import type { MarkoPressPlugin } from '../../plugin/types.js';
22
import type { SeoPluginFactoryOptions } from './types.js';
33
import { generateSitemap } from './sitemap.js';
4+
import { generateRobots } from './robots.js';
45

56
/**
6-
* SEO Plugin - Generates sitemap.xml for SEO
7+
* SEO Plugin - Generates sitemap.xml and robots.txt
78
*
89
* @example
910
* // In markopress config
@@ -23,18 +24,27 @@ export function seoPlugin(options?: SeoPluginFactoryOptions): MarkoPressPlugin {
2324

2425
async postBuild(ctx) {
2526
const { config } = ctx;
27+
const seoConfig = (options || (config as any).seo || {}) as any;
28+
const userSeoConfig = (config as any).seo;
2629

27-
// Get SEO config from user config
28-
const seoConfig = (config as any).seo;
30+
const hasSitemap = Boolean(seoConfig?.sitemap || userSeoConfig?.sitemap);
31+
const hasRobots = Boolean(seoConfig?.robots || userSeoConfig?.robots);
2932

30-
// Skip if sitemap not enabled
31-
if (!seoConfig?.sitemap) {
32-
console.log('[seo] Sitemap not configured, skipping');
33+
// Skip if no seo features are enabled
34+
if (!hasSitemap && !hasRobots) {
35+
console.log('[seo] No SEO generation configured, skipping');
3336
return;
3437
}
3538

36-
// Generate sitemap
37-
await generateSitemap(ctx, seoConfig.sitemap);
39+
if (seoConfig?.sitemap || userSeoConfig?.sitemap) {
40+
// Generate sitemap
41+
await generateSitemap(ctx, seoConfig.sitemap || userSeoConfig.sitemap);
42+
}
43+
44+
if (seoConfig?.robots || userSeoConfig?.robots) {
45+
// Generate robots.txt
46+
await generateRobots(ctx, seoConfig.robots || userSeoConfig.robots, seoConfig.sitemap || userSeoConfig?.sitemap);
47+
}
3848
},
3949
};
4050
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { generateRobots } from './robots.js';
3+
4+
// Mock fs
5+
vi.mock('fs', async () => {
6+
const actual = await vi.importActual('fs');
7+
return {
8+
...actual,
9+
promises: {
10+
writeFile: vi.fn(),
11+
mkdir: vi.fn().mockResolvedValue(undefined),
12+
},
13+
};
14+
});
15+
16+
// Import mocked modules
17+
import { promises as fs } from 'fs';
18+
19+
describe('generateRobots', () => {
20+
const mockBaseContext = {
21+
config: { site: { url: 'https://example.com', base: '/docs' } },
22+
outDir: '/tmp/dist',
23+
routes: {},
24+
assets: [],
25+
allContent: {},
26+
};
27+
28+
beforeEach(() => {
29+
vi.clearAllMocks();
30+
});
31+
32+
it('should generate robots.txt with basic rules', async () => {
33+
await generateRobots(mockBaseContext, {
34+
userAgent: ['Googlebot', 'Bingbot'],
35+
allow: ['/'],
36+
disallow: ['/admin'],
37+
crawlDelay: 5,
38+
});
39+
40+
expect(fs.writeFile).toHaveBeenCalledWith(
41+
'/tmp/dist/public/robots.txt',
42+
'User-agent: Googlebot\nAllow: /\nDisallow: /admin\nCrawl-delay: 5\n\nUser-agent: Bingbot\nAllow: /\nDisallow: /admin\nCrawl-delay: 5\n',
43+
'utf8'
44+
);
45+
});
46+
47+
it('should include sitemap reference when sitemap options are provided', async () => {
48+
await generateRobots(mockBaseContext, {
49+
disallow: ['/private'],
50+
}, {
51+
hostname: 'https://example.com',
52+
});
53+
54+
expect(fs.writeFile).toHaveBeenCalledWith(
55+
'/tmp/dist/public/robots.txt',
56+
expect.stringContaining('Sitemap: https://example.com/docs/sitemap.xml'),
57+
'utf8'
58+
);
59+
});
60+
61+
it('should use explicit robots sitemap path', async () => {
62+
await generateRobots(mockBaseContext, {
63+
disallow: ['/private'],
64+
sitemap: 'custom-sitemap.xml',
65+
}, {
66+
hostname: 'https://example.com',
67+
});
68+
69+
expect(fs.writeFile).toHaveBeenCalledWith(
70+
'/tmp/dist/public/robots.txt',
71+
expect.stringContaining('Sitemap: https://example.com/docs/custom-sitemap.xml'),
72+
'utf8'
73+
);
74+
});
75+
76+
it('should generate fallback file without site url when no sitemap options are set', async () => {
77+
await generateRobots(
78+
{
79+
...mockBaseContext,
80+
config: { site: {} },
81+
},
82+
{
83+
allow: ['/'],
84+
}
85+
);
86+
87+
const writeCall = vi.mocked(fs.writeFile).mock.calls[0];
88+
const content = writeCall[1] as string;
89+
90+
expect(writeCall[0]).toBe('/tmp/dist/public/robots.txt');
91+
expect(content).toContain('User-agent: *');
92+
expect(content).not.toContain('Sitemap:');
93+
});
94+
});

0 commit comments

Comments
 (0)