@knowcode/doc-builder 1.4.24 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +4 -1
- package/CHANGELOG.md +27 -0
- package/README.md +57 -7
- package/cli.js +251 -2
- package/doc-builder.config.js +38 -0
- package/html/README.html +71 -5
- package/html/claude-workflow-guide.html +71 -5
- package/html/documentation-index.html +77 -5
- package/html/guides/authentication-guide.html +77 -5
- package/html/guides/documentation-standards.html +77 -5
- package/html/guides/seo-guide.html +557 -0
- package/html/guides/troubleshooting-guide.html +77 -5
- package/html/index.html +71 -5
- package/html/robots.txt +5 -0
- package/html/sitemap.xml +69 -0
- package/html/vercel-cli-setup-guide.html +452 -0
- package/html/vercel-first-time-setup-guide.html +419 -0
- package/lib/config.js +39 -1
- package/lib/core-builder.js +103 -5
- package/lib/deploy.js +93 -36
- package/lib/seo.js +359 -0
- package/package.json +1 -1
package/lib/deploy.js
CHANGED
|
@@ -28,6 +28,13 @@ async function setupVercelProject(config) {
|
|
|
28
28
|
initial: config.siteName.toLowerCase().replace(/[^a-z0-9-]/g, '-') || 'my-docs',
|
|
29
29
|
hint: 'This will be your URL: project-name.vercel.app'
|
|
30
30
|
},
|
|
31
|
+
{
|
|
32
|
+
type: 'text',
|
|
33
|
+
name: 'productionUrl',
|
|
34
|
+
message: 'Custom production URL (optional)?',
|
|
35
|
+
initial: '',
|
|
36
|
+
hint: 'Leave empty for auto-detection, or enter your custom domain/alias (e.g., doc-builder-delta.vercel.app)'
|
|
37
|
+
},
|
|
31
38
|
{
|
|
32
39
|
type: 'select',
|
|
33
40
|
name: 'framework',
|
|
@@ -92,6 +99,27 @@ async function setupVercelProject(config) {
|
|
|
92
99
|
fs.writeJsonSync(vercelConfigPath, vercelConfig, { spaces: 2 });
|
|
93
100
|
console.log(chalk.green(`✅ Created vercel.json in ${config.outputDir || 'html'} directory`));
|
|
94
101
|
|
|
102
|
+
// Save production URL to config if provided
|
|
103
|
+
if (answers.productionUrl) {
|
|
104
|
+
const configPath = path.join(process.cwd(), 'doc-builder.config.js');
|
|
105
|
+
if (fs.existsSync(configPath)) {
|
|
106
|
+
try {
|
|
107
|
+
let configContent = fs.readFileSync(configPath, 'utf8');
|
|
108
|
+
// Add productionUrl to the config
|
|
109
|
+
if (!configContent.includes('productionUrl:')) {
|
|
110
|
+
configContent = configContent.replace(
|
|
111
|
+
/module\.exports = {/,
|
|
112
|
+
`module.exports = {\n productionUrl: '${answers.productionUrl.startsWith('http') ? answers.productionUrl : 'https://' + answers.productionUrl}',`
|
|
113
|
+
);
|
|
114
|
+
fs.writeFileSync(configPath, configContent);
|
|
115
|
+
console.log(chalk.green(`✅ Saved production URL to config: ${answers.productionUrl}`));
|
|
116
|
+
}
|
|
117
|
+
} catch (e) {
|
|
118
|
+
console.log(chalk.yellow('⚠️ Could not save production URL to config file'));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
95
123
|
// Run Vercel setup with prominent instructions
|
|
96
124
|
console.log(chalk.blue('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
|
|
97
125
|
console.log(chalk.blue('🔗 Linking to Vercel - IMPORTANT INSTRUCTIONS'));
|
|
@@ -112,8 +140,13 @@ async function setupVercelProject(config) {
|
|
|
112
140
|
console.log(chalk.white(' 👉 Answer: ') + chalk.yellow.bold('YES') + ' if you have an existing project');
|
|
113
141
|
console.log(chalk.white(' 👉 Answer: ') + chalk.yellow.bold('NO') + ' to create new project\n');
|
|
114
142
|
|
|
115
|
-
console.log(chalk.white('5️⃣ ') + chalk.
|
|
116
|
-
console.log(chalk.white('
|
|
143
|
+
console.log(chalk.white('5️⃣ ') + chalk.yellow('If you answered YES to #4:'));
|
|
144
|
+
console.log(chalk.white(' ') + chalk.green('What\'s the name of your existing project?'));
|
|
145
|
+
console.log(chalk.white(' 👉 Answer: ') + chalk.yellow.bold(answers.projectName) + ' (your existing project name)\n');
|
|
146
|
+
|
|
147
|
+
console.log(chalk.white('5️⃣ ') + chalk.yellow('If you answered NO to #4:'));
|
|
148
|
+
console.log(chalk.white(' ') + chalk.green('What is your project name?'));
|
|
149
|
+
console.log(chalk.white(' 👉 Answer: ') + chalk.yellow.bold(answers.projectName) + ' (e.g., "my-docs" or "gasworld")\n');
|
|
117
150
|
|
|
118
151
|
console.log(chalk.red.bold('⚠️ CRITICAL WARNING ABOUT ROOT DIRECTORY:\n'));
|
|
119
152
|
console.log(chalk.bgRed.white.bold(' If Vercel asks about Root Directory or shows it in settings: '));
|
|
@@ -282,55 +315,79 @@ async function deployToVercel(config, isProd = false) {
|
|
|
282
315
|
|
|
283
316
|
vercelProcess.on('close', async (code) => {
|
|
284
317
|
if (code === 0) {
|
|
285
|
-
// Try to get the production URL from Vercel
|
|
318
|
+
// Try to get the production URL from Vercel
|
|
319
|
+
let productionUrl = null;
|
|
320
|
+
|
|
321
|
+
// First, check if we have a configured production URL
|
|
322
|
+
if (config.productionUrl) {
|
|
323
|
+
productionUrl = config.productionUrl;
|
|
324
|
+
console.log(chalk.blue('\n📌 Using configured production URL'));
|
|
325
|
+
}
|
|
326
|
+
|
|
286
327
|
try {
|
|
287
328
|
const { execSync } = require('child_process');
|
|
288
|
-
const projectInfo = execSync('vercel project ls', {
|
|
289
|
-
cwd: outputPath,
|
|
290
|
-
encoding: 'utf8',
|
|
291
|
-
stdio: ['pipe', 'pipe', 'ignore']
|
|
292
|
-
});
|
|
293
|
-
|
|
294
|
-
// Try to determine the project name from the deployment URL or .vercel/project.json
|
|
295
|
-
let projectName = null;
|
|
296
329
|
|
|
297
|
-
//
|
|
330
|
+
// Method 1: Check for project domains
|
|
298
331
|
try {
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
332
|
+
const domainsOutput = execSync('vercel domains ls', {
|
|
333
|
+
cwd: outputPath,
|
|
334
|
+
encoding: 'utf8',
|
|
335
|
+
stdio: ['pipe', 'pipe', 'ignore']
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// Look for domains associated with this project
|
|
339
|
+
const domainLines = domainsOutput.split('\n');
|
|
340
|
+
for (const line of domainLines) {
|
|
341
|
+
if (line.includes('.vercel.app') && !line.includes('source')) {
|
|
342
|
+
// Extract domain that looks like a custom project domain
|
|
343
|
+
const match = line.match(/([a-z0-9-]+\.vercel\.app)/);
|
|
344
|
+
if (match && !match[1].includes('lindsay-1340s-projects')) {
|
|
345
|
+
productionUrl = `https://${match[1]}`;
|
|
346
|
+
break;
|
|
309
347
|
}
|
|
310
348
|
}
|
|
311
349
|
}
|
|
312
350
|
} catch (e) {
|
|
313
|
-
//
|
|
351
|
+
// domains command might not be available
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Method 2: Get project info and construct standard production URL
|
|
355
|
+
if (!productionUrl) {
|
|
356
|
+
try {
|
|
357
|
+
// Get the project name from the deployment
|
|
358
|
+
const inspectOutput = execSync(`vercel inspect ${deployUrl}`, {
|
|
359
|
+
cwd: outputPath,
|
|
360
|
+
encoding: 'utf8',
|
|
361
|
+
stdio: ['pipe', 'pipe', 'ignore']
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// Extract project name from inspect output
|
|
365
|
+
const projectMatch = inspectOutput.match(/Project Name:\s+([^\s]+)/);
|
|
366
|
+
if (projectMatch) {
|
|
367
|
+
const projectName = projectMatch[1];
|
|
368
|
+
// Construct the standard production URL
|
|
369
|
+
productionUrl = `https://${projectName}.vercel.app`;
|
|
370
|
+
}
|
|
371
|
+
} catch (e) {
|
|
372
|
+
// inspect command failed
|
|
373
|
+
}
|
|
314
374
|
}
|
|
315
375
|
|
|
316
|
-
// Extract
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
//
|
|
320
|
-
const urlMatch =
|
|
321
|
-
if (urlMatch
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
)) {
|
|
325
|
-
resolve({ deployUrl, productionUrl: urlMatch[0] });
|
|
326
|
-
return;
|
|
376
|
+
// Method 3: Extract from deployment URL pattern
|
|
377
|
+
if (!productionUrl && deployUrl) {
|
|
378
|
+
// Try to extract project name from deployment URL
|
|
379
|
+
// Format: https://project-name-randomhash-team.vercel.app
|
|
380
|
+
const urlMatch = deployUrl.match(/https:\/\/([^-]+)-[a-z0-9]+-/);
|
|
381
|
+
if (urlMatch) {
|
|
382
|
+
const projectName = urlMatch[1];
|
|
383
|
+
productionUrl = `https://${projectName}.vercel.app`;
|
|
327
384
|
}
|
|
328
385
|
}
|
|
329
386
|
} catch (err) {
|
|
330
|
-
//
|
|
387
|
+
// All methods failed, use deployment URL
|
|
331
388
|
}
|
|
332
389
|
|
|
333
|
-
resolve({ deployUrl, productionUrl
|
|
390
|
+
resolve({ deployUrl, productionUrl });
|
|
334
391
|
} else {
|
|
335
392
|
reject(new Error(`Vercel deployment failed with code ${code}`));
|
|
336
393
|
}
|
package/lib/seo.js
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
const fs = require('fs-extra');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const chalk = require('chalk');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* SEO Utilities for @knowcode/doc-builder
|
|
7
|
+
* Handles meta tags, structured data, sitemaps, and robots.txt
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Extract keywords from markdown content
|
|
12
|
+
*/
|
|
13
|
+
function extractKeywords(content, maxKeywords = 10) {
|
|
14
|
+
// Remove markdown syntax
|
|
15
|
+
const plainText = content
|
|
16
|
+
.replace(/```[\s\S]*?```/g, '') // Remove code blocks
|
|
17
|
+
.replace(/`[^`]*`/g, '') // Remove inline code
|
|
18
|
+
.replace(/#+\s/g, '') // Remove headers
|
|
19
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Extract link text
|
|
20
|
+
.replace(/[*_~]/g, '') // Remove emphasis
|
|
21
|
+
.toLowerCase();
|
|
22
|
+
|
|
23
|
+
// Common words to exclude
|
|
24
|
+
const stopWords = new Set([
|
|
25
|
+
'the', 'is', 'at', 'which', 'on', 'a', 'an', 'and', 'or', 'but',
|
|
26
|
+
'in', 'with', 'to', 'for', 'of', 'as', 'by', 'that', 'this',
|
|
27
|
+
'it', 'from', 'be', 'are', 'been', 'was', 'were', 'being'
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
// Extract words
|
|
31
|
+
const words = plainText.match(/\b[a-z]{3,}\b/g) || [];
|
|
32
|
+
|
|
33
|
+
// Count word frequency
|
|
34
|
+
const wordCount = {};
|
|
35
|
+
words.forEach(word => {
|
|
36
|
+
if (!stopWords.has(word)) {
|
|
37
|
+
wordCount[word] = (wordCount[word] || 0) + 1;
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Sort by frequency and return top keywords
|
|
42
|
+
return Object.entries(wordCount)
|
|
43
|
+
.sort((a, b) => b[1] - a[1])
|
|
44
|
+
.slice(0, maxKeywords)
|
|
45
|
+
.map(([word]) => word);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Generate meta description from content
|
|
50
|
+
*/
|
|
51
|
+
function generateDescription(content, maxLength = 160) {
|
|
52
|
+
// Remove markdown syntax and get first paragraph
|
|
53
|
+
const plainText = content
|
|
54
|
+
.replace(/```[\s\S]*?```/g, '') // Remove code blocks
|
|
55
|
+
.replace(/#+\s(.+)/g, '$1. ') // Convert headers to sentences
|
|
56
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Extract link text
|
|
57
|
+
.replace(/[*_~]/g, '') // Remove emphasis
|
|
58
|
+
.replace(/\n+/g, ' ') // Replace newlines with spaces
|
|
59
|
+
.trim();
|
|
60
|
+
|
|
61
|
+
// Find first sentence or use first maxLength characters
|
|
62
|
+
const firstSentence = plainText.match(/^[^.!?]+[.!?]/);
|
|
63
|
+
let description = firstSentence ? firstSentence[0] : plainText;
|
|
64
|
+
|
|
65
|
+
// Truncate if needed
|
|
66
|
+
if (description.length > maxLength) {
|
|
67
|
+
description = description.substring(0, maxLength - 3) + '...';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return description;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Generate meta tags for a page
|
|
75
|
+
*/
|
|
76
|
+
function generateMetaTags(options) {
|
|
77
|
+
const {
|
|
78
|
+
title,
|
|
79
|
+
description,
|
|
80
|
+
url,
|
|
81
|
+
author,
|
|
82
|
+
keywords,
|
|
83
|
+
twitterHandle,
|
|
84
|
+
ogImage,
|
|
85
|
+
siteName,
|
|
86
|
+
language = 'en-US',
|
|
87
|
+
type = 'article'
|
|
88
|
+
} = options;
|
|
89
|
+
|
|
90
|
+
const tags = [];
|
|
91
|
+
|
|
92
|
+
// Basic meta tags
|
|
93
|
+
if (author) {
|
|
94
|
+
tags.push(` <meta name="author" content="${escapeHtml(author)}">`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (keywords && keywords.length > 0) {
|
|
98
|
+
const keywordString = Array.isArray(keywords) ? keywords.join(', ') : keywords;
|
|
99
|
+
tags.push(` <meta name="keywords" content="${escapeHtml(keywordString)}">`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
tags.push(` <meta name="robots" content="index, follow">`);
|
|
103
|
+
|
|
104
|
+
if (url) {
|
|
105
|
+
tags.push(` <link rel="canonical" href="${url}">`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Open Graph tags
|
|
109
|
+
tags.push(` \n <!-- Open Graph / Facebook -->`);
|
|
110
|
+
tags.push(` <meta property="og:type" content="${type}">`);
|
|
111
|
+
|
|
112
|
+
if (url) {
|
|
113
|
+
tags.push(` <meta property="og:url" content="${url}">`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (title) {
|
|
117
|
+
tags.push(` <meta property="og:title" content="${escapeHtml(title)}">`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (description) {
|
|
121
|
+
tags.push(` <meta property="og:description" content="${escapeHtml(description)}">`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (ogImage) {
|
|
125
|
+
const imageUrl = ogImage.startsWith('http') ? ogImage : (url ? new URL(ogImage, url).href : ogImage);
|
|
126
|
+
tags.push(` <meta property="og:image" content="${imageUrl}">`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (siteName) {
|
|
130
|
+
tags.push(` <meta property="og:site_name" content="${escapeHtml(siteName)}">`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
tags.push(` <meta property="og:locale" content="${language.replace('-', '_')}">`);
|
|
134
|
+
|
|
135
|
+
// Twitter Card tags
|
|
136
|
+
if (twitterHandle || ogImage) {
|
|
137
|
+
tags.push(` \n <!-- Twitter Card -->`);
|
|
138
|
+
tags.push(` <meta name="twitter:card" content="summary_large_image">`);
|
|
139
|
+
|
|
140
|
+
if (twitterHandle) {
|
|
141
|
+
const handle = twitterHandle.startsWith('@') ? twitterHandle : '@' + twitterHandle;
|
|
142
|
+
tags.push(` <meta name="twitter:site" content="${handle}">`);
|
|
143
|
+
tags.push(` <meta name="twitter:creator" content="${handle}">`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (title) {
|
|
147
|
+
tags.push(` <meta name="twitter:title" content="${escapeHtml(title)}">`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (description) {
|
|
151
|
+
tags.push(` <meta name="twitter:description" content="${escapeHtml(description)}">`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (ogImage) {
|
|
155
|
+
const imageUrl = ogImage.startsWith('http') ? ogImage : (url ? new URL(ogImage, url).href : ogImage);
|
|
156
|
+
tags.push(` <meta name="twitter:image" content="${imageUrl}">`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return tags.join('\n');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Generate JSON-LD structured data
|
|
165
|
+
*/
|
|
166
|
+
function generateJSONLD(options) {
|
|
167
|
+
const {
|
|
168
|
+
title,
|
|
169
|
+
description,
|
|
170
|
+
url,
|
|
171
|
+
author,
|
|
172
|
+
siteName,
|
|
173
|
+
datePublished,
|
|
174
|
+
dateModified,
|
|
175
|
+
breadcrumbs,
|
|
176
|
+
organization,
|
|
177
|
+
type = 'TechArticle'
|
|
178
|
+
} = options;
|
|
179
|
+
|
|
180
|
+
const jsonld = {
|
|
181
|
+
'@context': 'https://schema.org',
|
|
182
|
+
'@type': type,
|
|
183
|
+
headline: title,
|
|
184
|
+
description: description
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
if (author) {
|
|
188
|
+
jsonld.author = {
|
|
189
|
+
'@type': 'Person',
|
|
190
|
+
name: author
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (organization) {
|
|
195
|
+
jsonld.publisher = {
|
|
196
|
+
'@type': 'Organization',
|
|
197
|
+
name: organization.name,
|
|
198
|
+
url: organization.url
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
if (organization.logo) {
|
|
202
|
+
jsonld.publisher.logo = {
|
|
203
|
+
'@type': 'ImageObject',
|
|
204
|
+
url: organization.logo
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (datePublished) {
|
|
210
|
+
jsonld.datePublished = datePublished;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (dateModified) {
|
|
214
|
+
jsonld.dateModified = dateModified;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (url) {
|
|
218
|
+
jsonld.mainEntityOfPage = {
|
|
219
|
+
'@type': 'WebPage',
|
|
220
|
+
'@id': url
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Add breadcrumb if provided
|
|
225
|
+
if (breadcrumbs && breadcrumbs.length > 0) {
|
|
226
|
+
jsonld.breadcrumb = {
|
|
227
|
+
'@type': 'BreadcrumbList',
|
|
228
|
+
itemListElement: breadcrumbs.map((crumb, index) => ({
|
|
229
|
+
'@type': 'ListItem',
|
|
230
|
+
position: index + 1,
|
|
231
|
+
name: crumb.name,
|
|
232
|
+
item: crumb.url
|
|
233
|
+
}))
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return `<script type="application/ld+json">\n${JSON.stringify(jsonld, null, 2)}\n</script>`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Generate sitemap.xml
|
|
242
|
+
*/
|
|
243
|
+
async function generateSitemap(pages, siteUrl, outputDir) {
|
|
244
|
+
console.log(chalk.blue('🗺️ Generating sitemap.xml...'));
|
|
245
|
+
|
|
246
|
+
const sitemap = [
|
|
247
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
248
|
+
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'
|
|
249
|
+
];
|
|
250
|
+
|
|
251
|
+
const now = new Date().toISOString();
|
|
252
|
+
|
|
253
|
+
pages.forEach(page => {
|
|
254
|
+
const priority = page.path === 'index.html' ? '1.0' :
|
|
255
|
+
page.path.includes('guide') ? '0.8' : '0.6';
|
|
256
|
+
const changefreq = page.path === 'index.html' ? 'weekly' : 'monthly';
|
|
257
|
+
|
|
258
|
+
sitemap.push(' <url>');
|
|
259
|
+
sitemap.push(` <loc>${siteUrl}/${page.path}</loc>`);
|
|
260
|
+
sitemap.push(` <lastmod>${now}</lastmod>`);
|
|
261
|
+
sitemap.push(` <changefreq>${changefreq}</changefreq>`);
|
|
262
|
+
sitemap.push(` <priority>${priority}</priority>`);
|
|
263
|
+
sitemap.push(' </url>');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
sitemap.push('</urlset>');
|
|
267
|
+
|
|
268
|
+
const sitemapPath = path.join(outputDir, 'sitemap.xml');
|
|
269
|
+
await fs.writeFile(sitemapPath, sitemap.join('\n'));
|
|
270
|
+
console.log(chalk.green(`✅ Generated sitemap.xml with ${pages.length} URLs`));
|
|
271
|
+
|
|
272
|
+
return sitemapPath;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Generate robots.txt
|
|
277
|
+
*/
|
|
278
|
+
async function generateRobotsTxt(siteUrl, outputDir, options = {}) {
|
|
279
|
+
console.log(chalk.blue('🤖 Generating robots.txt...'));
|
|
280
|
+
|
|
281
|
+
const robots = [
|
|
282
|
+
'User-agent: *',
|
|
283
|
+
'Allow: /',
|
|
284
|
+
'',
|
|
285
|
+
'# Sitemap location',
|
|
286
|
+
`Sitemap: ${siteUrl}/sitemap.xml`
|
|
287
|
+
];
|
|
288
|
+
|
|
289
|
+
// Add disallow for auth pages if authentication is enabled
|
|
290
|
+
if (options.hasAuthentication) {
|
|
291
|
+
robots.push('', '# Authentication pages');
|
|
292
|
+
robots.push('Disallow: /login.html');
|
|
293
|
+
robots.push('Disallow: /logout.html');
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Add custom rules if provided
|
|
297
|
+
if (options.customRules) {
|
|
298
|
+
robots.push('', '# Custom rules');
|
|
299
|
+
robots.push(...options.customRules);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
robots.push('');
|
|
303
|
+
|
|
304
|
+
const robotsPath = path.join(outputDir, 'robots.txt');
|
|
305
|
+
await fs.writeFile(robotsPath, robots.join('\n'));
|
|
306
|
+
console.log(chalk.green('✅ Generated robots.txt'));
|
|
307
|
+
|
|
308
|
+
return robotsPath;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Escape HTML special characters
|
|
313
|
+
*/
|
|
314
|
+
function escapeHtml(str) {
|
|
315
|
+
if (!str) return '';
|
|
316
|
+
return str
|
|
317
|
+
.replace(/&/g, '&')
|
|
318
|
+
.replace(/</g, '<')
|
|
319
|
+
.replace(/>/g, '>')
|
|
320
|
+
.replace(/"/g, '"')
|
|
321
|
+
.replace(/'/g, ''');
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Generate breadcrumbs from file path
|
|
326
|
+
*/
|
|
327
|
+
function generateBreadcrumbs(filePath, siteUrl, siteName) {
|
|
328
|
+
const parts = filePath.split('/').filter(p => p && p !== 'index.html');
|
|
329
|
+
const breadcrumbs = [
|
|
330
|
+
{ name: siteName || 'Home', url: siteUrl }
|
|
331
|
+
];
|
|
332
|
+
|
|
333
|
+
let currentPath = '';
|
|
334
|
+
parts.forEach((part, index) => {
|
|
335
|
+
currentPath += (currentPath ? '/' : '') + part;
|
|
336
|
+
const name = part
|
|
337
|
+
.replace('.html', '')
|
|
338
|
+
.replace(/-/g, ' ')
|
|
339
|
+
.replace(/\b\w/g, l => l.toUpperCase());
|
|
340
|
+
|
|
341
|
+
breadcrumbs.push({
|
|
342
|
+
name: name,
|
|
343
|
+
url: `${siteUrl}/${currentPath}${part.endsWith('.html') ? '' : '/'}`
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
return breadcrumbs;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
module.exports = {
|
|
351
|
+
extractKeywords,
|
|
352
|
+
generateDescription,
|
|
353
|
+
generateMetaTags,
|
|
354
|
+
generateJSONLD,
|
|
355
|
+
generateSitemap,
|
|
356
|
+
generateRobotsTxt,
|
|
357
|
+
generateBreadcrumbs,
|
|
358
|
+
escapeHtml
|
|
359
|
+
};
|