@djangocfg/seo 2.1.50
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/README.md +192 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.mjs +3780 -0
- package/dist/cli.mjs.map +1 -0
- package/dist/crawler/index.d.ts +88 -0
- package/dist/crawler/index.mjs +610 -0
- package/dist/crawler/index.mjs.map +1 -0
- package/dist/google-console/index.d.ts +95 -0
- package/dist/google-console/index.mjs +539 -0
- package/dist/google-console/index.mjs.map +1 -0
- package/dist/index.d.ts +285 -0
- package/dist/index.mjs +3236 -0
- package/dist/index.mjs.map +1 -0
- package/dist/link-checker/index.d.ts +76 -0
- package/dist/link-checker/index.mjs +326 -0
- package/dist/link-checker/index.mjs.map +1 -0
- package/dist/markdown-report-B3QdDzxE.d.ts +193 -0
- package/dist/reports/index.d.ts +24 -0
- package/dist/reports/index.mjs +836 -0
- package/dist/reports/index.mjs.map +1 -0
- package/dist/routes/index.d.ts +69 -0
- package/dist/routes/index.mjs +372 -0
- package/dist/routes/index.mjs.map +1 -0
- package/dist/scanner-Cz4Th2Pt.d.ts +60 -0
- package/dist/types/index.d.ts +144 -0
- package/dist/types/index.mjs +3 -0
- package/dist/types/index.mjs.map +1 -0
- package/package.json +114 -0
- package/src/analyzer.ts +256 -0
- package/src/cli/commands/audit.ts +260 -0
- package/src/cli/commands/content.ts +180 -0
- package/src/cli/commands/crawl.ts +32 -0
- package/src/cli/commands/index.ts +12 -0
- package/src/cli/commands/inspect.ts +60 -0
- package/src/cli/commands/links.ts +41 -0
- package/src/cli/commands/robots.ts +36 -0
- package/src/cli/commands/routes.ts +126 -0
- package/src/cli/commands/sitemap.ts +48 -0
- package/src/cli/index.ts +149 -0
- package/src/cli/types.ts +40 -0
- package/src/config.ts +207 -0
- package/src/content/index.ts +51 -0
- package/src/content/link-checker.ts +182 -0
- package/src/content/link-fixer.ts +188 -0
- package/src/content/scanner.ts +200 -0
- package/src/content/sitemap-generator.ts +321 -0
- package/src/content/types.ts +140 -0
- package/src/crawler/crawler.ts +425 -0
- package/src/crawler/index.ts +10 -0
- package/src/crawler/robots-parser.ts +171 -0
- package/src/crawler/sitemap-validator.ts +204 -0
- package/src/google-console/analyzer.ts +317 -0
- package/src/google-console/auth.ts +100 -0
- package/src/google-console/client.ts +281 -0
- package/src/google-console/index.ts +9 -0
- package/src/index.ts +144 -0
- package/src/link-checker/index.ts +461 -0
- package/src/reports/claude-context.ts +149 -0
- package/src/reports/generator.ts +244 -0
- package/src/reports/index.ts +27 -0
- package/src/reports/json-report.ts +320 -0
- package/src/reports/markdown-report.ts +246 -0
- package/src/reports/split-report.ts +252 -0
- package/src/routes/analyzer.ts +324 -0
- package/src/routes/index.ts +25 -0
- package/src/routes/scanner.ts +298 -0
- package/src/types/index.ts +222 -0
- package/src/utils/index.ts +154 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Link checker for MDX documentation
|
|
3
|
+
* Checks all internal links in content/ folder and verifies pages exist
|
|
4
|
+
* Properly handles Nextra's routing behavior for relative links
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import fs from 'fs';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import type {
|
|
10
|
+
ContentConfig,
|
|
11
|
+
ExtractedLink,
|
|
12
|
+
BrokenLink,
|
|
13
|
+
LinkCheckResult,
|
|
14
|
+
LinkType,
|
|
15
|
+
DEFAULT_CONFIG,
|
|
16
|
+
} from './types.js';
|
|
17
|
+
import { getAllMdxFiles, getFileInfo, pathExists } from './scanner.js';
|
|
18
|
+
|
|
19
|
+
// Link patterns for MDX files (handles optional #anchor)
|
|
20
|
+
const LINK_PATTERNS: { regex: RegExp; type: LinkType }[] = [
|
|
21
|
+
// Absolute links: [text](/docs/path)
|
|
22
|
+
{ regex: /\]\(\/docs\/([^)#\s"]+)/g, type: 'absolute' },
|
|
23
|
+
{ regex: /href="\/docs\/([^"#]+)"/g, type: 'absolute' },
|
|
24
|
+
{ regex: /to="\/docs\/([^"#]+)"/g, type: 'absolute' },
|
|
25
|
+
// Dot-slash relative: [text](./path)
|
|
26
|
+
{ regex: /\]\(\.\/([^)#\s"]+)(?:#[^)]*)?\)/g, type: 'dotslash' },
|
|
27
|
+
{ regex: /href="\.\/([^"#]+)"/g, type: 'dotslash' },
|
|
28
|
+
// Parent relative: [text](../path)
|
|
29
|
+
{ regex: /\]\(\.\.\/([^)#\s"]+)(?:#[^)]*)?\)/g, type: 'parent' },
|
|
30
|
+
{ regex: /href="\.\.\/([^"#]+)"/g, type: 'parent' },
|
|
31
|
+
// Simple relative (no prefix): [text](path)
|
|
32
|
+
{ regex: /\]\((?!\/|http|#|\.|\[)([a-zA-Z][^)#\s"]*)(?:#[^)]*)?\)/g, type: 'simple' },
|
|
33
|
+
{ regex: /href="(?!\/|http|#|\.)([a-zA-Z][^"#]*)"/g, type: 'simple' },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Check if link points to an asset file
|
|
38
|
+
*/
|
|
39
|
+
function isAssetLink(linkPath: string, assetExtensions: string[]): boolean {
|
|
40
|
+
return assetExtensions.some(ext => linkPath.toLowerCase().endsWith(ext));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolve a link to an absolute docs path
|
|
45
|
+
* Mimics how Nextra/browser resolves links
|
|
46
|
+
*/
|
|
47
|
+
function resolveLink(
|
|
48
|
+
fromFilePath: string,
|
|
49
|
+
linkPath: string,
|
|
50
|
+
linkType: LinkType,
|
|
51
|
+
contentDir: string
|
|
52
|
+
): string {
|
|
53
|
+
if (linkType === 'absolute') {
|
|
54
|
+
return linkPath;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const { isIndex, folder: sourceFolder, name: fileName } = getFileInfo(fromFilePath, contentDir);
|
|
58
|
+
const sourceParts = sourceFolder ? sourceFolder.split('/') : [];
|
|
59
|
+
|
|
60
|
+
if (linkType === 'dotslash' || linkType === 'simple') {
|
|
61
|
+
// ./ or simple relative
|
|
62
|
+
if (isIndex) {
|
|
63
|
+
// index.mdx at /foo/ -> ./bar resolves to /foo/bar
|
|
64
|
+
return sourceFolder ? `${sourceFolder}/${linkPath}` : linkPath;
|
|
65
|
+
} else {
|
|
66
|
+
// page.mdx at /foo/page -> ./bar resolves to /foo/page/bar (browser behavior!)
|
|
67
|
+
return sourceFolder ? `${sourceFolder}/${fileName}/${linkPath}` : `${fileName}/${linkPath}`;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (linkType === 'parent') {
|
|
72
|
+
// ../bar
|
|
73
|
+
if (isIndex) {
|
|
74
|
+
// index.mdx at /foo/ -> ../bar resolves to /bar (go up from foo)
|
|
75
|
+
const newParts = [...sourceParts];
|
|
76
|
+
newParts.pop();
|
|
77
|
+
return newParts.length ? `${newParts.join('/')}/${linkPath}` : linkPath;
|
|
78
|
+
} else {
|
|
79
|
+
// page.mdx at /foo/page -> ../bar resolves to /foo/bar
|
|
80
|
+
return sourceParts.length ? `${sourceParts.join('/')}/${linkPath}` : linkPath;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return linkPath;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Extract all links from a file
|
|
89
|
+
*/
|
|
90
|
+
function extractLinks(
|
|
91
|
+
filePath: string,
|
|
92
|
+
contentDir: string,
|
|
93
|
+
assetExtensions: string[]
|
|
94
|
+
): ExtractedLink[] {
|
|
95
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
96
|
+
const links: ExtractedLink[] = [];
|
|
97
|
+
|
|
98
|
+
for (const { regex, type } of LINK_PATTERNS) {
|
|
99
|
+
regex.lastIndex = 0;
|
|
100
|
+
let match;
|
|
101
|
+
while ((match = regex.exec(content)) !== null) {
|
|
102
|
+
const rawLink = match[1];
|
|
103
|
+
if (!rawLink) continue;
|
|
104
|
+
|
|
105
|
+
// Skip asset links
|
|
106
|
+
if (isAssetLink(rawLink, assetExtensions)) continue;
|
|
107
|
+
|
|
108
|
+
const resolved = resolveLink(filePath, rawLink, type, contentDir);
|
|
109
|
+
links.push({
|
|
110
|
+
raw: rawLink,
|
|
111
|
+
resolved,
|
|
112
|
+
type,
|
|
113
|
+
line: content.substring(0, match.index).split('\n').length,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return links;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Check all links in content directory
|
|
123
|
+
*/
|
|
124
|
+
export function checkContentLinks(
|
|
125
|
+
contentDir: string,
|
|
126
|
+
config?: Partial<ContentConfig>
|
|
127
|
+
): LinkCheckResult {
|
|
128
|
+
const assetExtensions = config?.assetExtensions || [
|
|
129
|
+
'.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico', '.pdf', '.zip', '.tar', '.gz'
|
|
130
|
+
];
|
|
131
|
+
const basePath = config?.basePath || '/docs';
|
|
132
|
+
|
|
133
|
+
const files = getAllMdxFiles(contentDir);
|
|
134
|
+
const brokenLinks: BrokenLink[] = [];
|
|
135
|
+
const checkedLinks = new Map<string, boolean>();
|
|
136
|
+
|
|
137
|
+
for (const file of files) {
|
|
138
|
+
const links = extractLinks(file, contentDir, assetExtensions);
|
|
139
|
+
const relativePath = path.relative(contentDir, file);
|
|
140
|
+
|
|
141
|
+
for (const link of links) {
|
|
142
|
+
// Check if we've already verified this path
|
|
143
|
+
if (!checkedLinks.has(link.resolved)) {
|
|
144
|
+
checkedLinks.set(link.resolved, pathExists(link.resolved, contentDir));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (!checkedLinks.get(link.resolved)) {
|
|
148
|
+
brokenLinks.push({
|
|
149
|
+
file: relativePath,
|
|
150
|
+
link: `${basePath}/${link.resolved}`,
|
|
151
|
+
type: link.type,
|
|
152
|
+
raw: link.raw,
|
|
153
|
+
line: link.line,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
filesChecked: files.length,
|
|
161
|
+
uniqueLinks: checkedLinks.size,
|
|
162
|
+
brokenLinks,
|
|
163
|
+
success: brokenLinks.length === 0,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Group broken links by file for display
|
|
169
|
+
*/
|
|
170
|
+
export function groupBrokenLinksByFile(
|
|
171
|
+
brokenLinks: BrokenLink[]
|
|
172
|
+
): Map<string, BrokenLink[]> {
|
|
173
|
+
const byFile = new Map<string, BrokenLink[]>();
|
|
174
|
+
|
|
175
|
+
for (const link of brokenLinks) {
|
|
176
|
+
const existing = byFile.get(link.file) || [];
|
|
177
|
+
existing.push(link);
|
|
178
|
+
byFile.set(link.file, existing);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return byFile;
|
|
182
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Link fixer for MDX documentation
|
|
3
|
+
* Converts absolute /docs/ links to relative when appropriate
|
|
4
|
+
*
|
|
5
|
+
* Nextra routing rules:
|
|
6
|
+
* - index.mdx at /foo/ -> ./bar resolves to /foo/bar ✓
|
|
7
|
+
* - page.mdx at /foo/page -> ./bar resolves to /foo/page/bar ✗ (browser behavior)
|
|
8
|
+
*
|
|
9
|
+
* So for non-index files linking to siblings, we need ../sibling
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from 'fs';
|
|
13
|
+
import path from 'path';
|
|
14
|
+
import type { ContentConfig, LinkFix, LinkFixResult, FixLinksResult } from './types.js';
|
|
15
|
+
import { getAllMdxFiles, getFileInfo, pathExists } from './scanner.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Check if link points to an asset file
|
|
19
|
+
*/
|
|
20
|
+
function isAssetLink(linkPath: string, assetExtensions: string[]): boolean {
|
|
21
|
+
return assetExtensions.some(ext => linkPath.toLowerCase().endsWith(ext));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Calculate relative path from source to target, respecting Nextra routing
|
|
26
|
+
* Returns null if can't be relative (target is outside source's scope)
|
|
27
|
+
*/
|
|
28
|
+
function calculateRelativePath(
|
|
29
|
+
sourceFile: string,
|
|
30
|
+
targetDocsPath: string,
|
|
31
|
+
contentDir: string
|
|
32
|
+
): string | null {
|
|
33
|
+
const { isIndex, folder: sourceFolder } = getFileInfo(sourceFile, contentDir);
|
|
34
|
+
const targetPath = targetDocsPath.replace(/^\//, '');
|
|
35
|
+
|
|
36
|
+
// Split paths into parts
|
|
37
|
+
const sourceParts = sourceFolder ? sourceFolder.split('/') : [];
|
|
38
|
+
const targetParts = targetPath.split('/');
|
|
39
|
+
|
|
40
|
+
// Find common prefix
|
|
41
|
+
let commonLength = 0;
|
|
42
|
+
for (let i = 0; i < Math.min(sourceParts.length, targetParts.length); i++) {
|
|
43
|
+
if (sourceParts[i] === targetParts[i]) {
|
|
44
|
+
commonLength++;
|
|
45
|
+
} else {
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// For index files: can use ./ for same folder or subfolders
|
|
51
|
+
// For non-index files: need to go up first with ../
|
|
52
|
+
|
|
53
|
+
if (isIndex) {
|
|
54
|
+
// index.mdx at /foo/ can use ./bar for /foo/bar
|
|
55
|
+
if (targetPath.startsWith(sourceFolder + '/') || sourceFolder === '') {
|
|
56
|
+
const relative = sourceFolder ? targetPath.slice(sourceFolder.length + 1) : targetPath;
|
|
57
|
+
return './' + relative;
|
|
58
|
+
}
|
|
59
|
+
// Target is outside - need ../
|
|
60
|
+
const upsNeeded = sourceParts.length - commonLength;
|
|
61
|
+
const downs = targetParts.slice(commonLength);
|
|
62
|
+
if (upsNeeded <= 1) {
|
|
63
|
+
return '../'.repeat(upsNeeded) + downs.join('/');
|
|
64
|
+
}
|
|
65
|
+
return null; // Too far up, keep absolute
|
|
66
|
+
} else {
|
|
67
|
+
// Non-index file at /foo/page needs ../bar for /foo/bar (sibling)
|
|
68
|
+
// and ./bar would go to /foo/page/bar which is wrong
|
|
69
|
+
|
|
70
|
+
if (targetPath.startsWith(sourceFolder + '/') || sourceFolder === '') {
|
|
71
|
+
// Same folder - target is sibling
|
|
72
|
+
const relative = sourceFolder ? targetPath.slice(sourceFolder.length + 1) : targetPath;
|
|
73
|
+
if (!relative.includes('/')) {
|
|
74
|
+
// Sibling file
|
|
75
|
+
return '../' + relative;
|
|
76
|
+
}
|
|
77
|
+
// Subfolder of same folder
|
|
78
|
+
return '../' + relative;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Different folder
|
|
82
|
+
const upsNeeded = sourceParts.length - commonLength + 1; // +1 because non-index
|
|
83
|
+
const downs = targetParts.slice(commonLength);
|
|
84
|
+
if (upsNeeded <= 2) {
|
|
85
|
+
return '../'.repeat(upsNeeded) + downs.join('/');
|
|
86
|
+
}
|
|
87
|
+
return null; // Too far, keep absolute
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Process a single file and find fixable links
|
|
93
|
+
*/
|
|
94
|
+
function processFile(
|
|
95
|
+
filePath: string,
|
|
96
|
+
contentDir: string,
|
|
97
|
+
assetExtensions: string[]
|
|
98
|
+
): LinkFix[] {
|
|
99
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
100
|
+
const fixes: LinkFix[] = [];
|
|
101
|
+
|
|
102
|
+
const patterns = [
|
|
103
|
+
{ regex: /(\]\()\/docs\/([^)#\s"]+)(\))/g },
|
|
104
|
+
{ regex: /(href=")\/docs\/([^"#]+)(")/g },
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
for (const { regex } of patterns) {
|
|
108
|
+
regex.lastIndex = 0;
|
|
109
|
+
let match;
|
|
110
|
+
|
|
111
|
+
while ((match = regex.exec(content)) !== null) {
|
|
112
|
+
const targetPath = match[2];
|
|
113
|
+
if (!targetPath) continue;
|
|
114
|
+
|
|
115
|
+
// Skip assets
|
|
116
|
+
if (isAssetLink(targetPath, assetExtensions)) continue;
|
|
117
|
+
|
|
118
|
+
// Skip if target doesn't exist
|
|
119
|
+
if (!pathExists(targetPath, contentDir)) continue;
|
|
120
|
+
|
|
121
|
+
const relativePath = calculateRelativePath(filePath, targetPath, contentDir);
|
|
122
|
+
|
|
123
|
+
if (relativePath) {
|
|
124
|
+
fixes.push({
|
|
125
|
+
from: `/docs/${targetPath}`,
|
|
126
|
+
to: relativePath,
|
|
127
|
+
line: content.substring(0, match.index).split('\n').length,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return fixes;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Apply fixes to a file
|
|
138
|
+
*/
|
|
139
|
+
function applyFixes(filePath: string, fixes: LinkFix[]): void {
|
|
140
|
+
let content = fs.readFileSync(filePath, 'utf-8');
|
|
141
|
+
|
|
142
|
+
for (const { from, to } of fixes) {
|
|
143
|
+
content = content.split(from).join(to);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
fs.writeFileSync(filePath, content, 'utf-8');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Fix absolute links to relative in content directory
|
|
151
|
+
*/
|
|
152
|
+
export function fixContentLinks(
|
|
153
|
+
contentDir: string,
|
|
154
|
+
options: { apply?: boolean; config?: Partial<ContentConfig> } = {}
|
|
155
|
+
): FixLinksResult {
|
|
156
|
+
const { apply = false, config } = options;
|
|
157
|
+
const assetExtensions = config?.assetExtensions || [
|
|
158
|
+
'.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico', '.pdf'
|
|
159
|
+
];
|
|
160
|
+
|
|
161
|
+
const files = getAllMdxFiles(contentDir);
|
|
162
|
+
let totalChanges = 0;
|
|
163
|
+
const fileChanges: LinkFixResult[] = [];
|
|
164
|
+
|
|
165
|
+
for (const file of files) {
|
|
166
|
+
const fixes = processFile(file, contentDir, assetExtensions);
|
|
167
|
+
|
|
168
|
+
if (fixes.length > 0) {
|
|
169
|
+
const relativePath = path.relative(contentDir, file);
|
|
170
|
+
fileChanges.push({
|
|
171
|
+
file: relativePath,
|
|
172
|
+
fullPath: file,
|
|
173
|
+
fixes,
|
|
174
|
+
});
|
|
175
|
+
totalChanges += fixes.length;
|
|
176
|
+
|
|
177
|
+
if (apply) {
|
|
178
|
+
applyFixes(file, fixes);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
totalChanges,
|
|
185
|
+
fileChanges,
|
|
186
|
+
applied: apply,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content scanner for MDX/Nextra projects
|
|
3
|
+
* Scans content/ and app/ directories for files
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import fs from 'fs';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import type { ContentConfig, FileInfo, ContentScanResult, DEFAULT_CONFIG } from './types.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Detect project type based on directory structure
|
|
12
|
+
*/
|
|
13
|
+
export function detectProjectType(cwd: string): 'nextra' | 'nextjs' | 'unknown' {
|
|
14
|
+
const contentDir = path.join(cwd, 'content');
|
|
15
|
+
const appDir = path.join(cwd, 'app');
|
|
16
|
+
|
|
17
|
+
if (fs.existsSync(contentDir)) {
|
|
18
|
+
// Check for _meta.ts files (Nextra indicator)
|
|
19
|
+
if (hasMetaFiles(contentDir)) {
|
|
20
|
+
return 'nextra';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (fs.existsSync(appDir)) {
|
|
25
|
+
return 'nextjs';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return 'unknown';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Check if directory contains _meta.ts files (Nextra pattern)
|
|
33
|
+
*/
|
|
34
|
+
export function hasMetaFiles(dir: string): boolean {
|
|
35
|
+
if (!fs.existsSync(dir)) return false;
|
|
36
|
+
|
|
37
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
38
|
+
|
|
39
|
+
for (const entry of entries) {
|
|
40
|
+
if (entry.name === '_meta.ts' || entry.name === '_meta.tsx') {
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
if (entry.isDirectory() && !entry.name.startsWith('.')) {
|
|
44
|
+
const subDir = path.join(dir, entry.name);
|
|
45
|
+
if (hasMetaFiles(subDir)) {
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Scan project and return content information
|
|
56
|
+
*/
|
|
57
|
+
export function scanProject(cwd: string, config?: Partial<ContentConfig>): ContentScanResult {
|
|
58
|
+
const contentDir = path.join(cwd, config?.contentDir || 'content');
|
|
59
|
+
const appDir = path.join(cwd, config?.appDir || 'app');
|
|
60
|
+
const extensions = config?.extensions || ['.mdx', '.md'];
|
|
61
|
+
|
|
62
|
+
const hasContent = fs.existsSync(contentDir);
|
|
63
|
+
const hasApp = fs.existsSync(appDir);
|
|
64
|
+
const projectType = detectProjectType(cwd);
|
|
65
|
+
|
|
66
|
+
const mdxFiles = hasContent ? getAllFiles(contentDir, extensions) : [];
|
|
67
|
+
const pageFiles = hasApp ? getPageFiles(appDir) : [];
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
projectType,
|
|
71
|
+
hasContent,
|
|
72
|
+
hasApp,
|
|
73
|
+
mdxFiles,
|
|
74
|
+
pageFiles,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Get all files with given extensions recursively
|
|
80
|
+
*/
|
|
81
|
+
export function getAllFiles(dir: string, extensions: string[], files: string[] = []): string[] {
|
|
82
|
+
if (!fs.existsSync(dir)) return files;
|
|
83
|
+
|
|
84
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
85
|
+
|
|
86
|
+
for (const entry of entries) {
|
|
87
|
+
const fullPath = path.join(dir, entry.name);
|
|
88
|
+
|
|
89
|
+
if (entry.isDirectory()) {
|
|
90
|
+
if (!entry.name.startsWith('.') && entry.name !== 'node_modules') {
|
|
91
|
+
getAllFiles(fullPath, extensions, files);
|
|
92
|
+
}
|
|
93
|
+
} else {
|
|
94
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
95
|
+
if (extensions.includes(ext)) {
|
|
96
|
+
files.push(fullPath);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return files;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Get all MDX files in content directory
|
|
106
|
+
*/
|
|
107
|
+
export function getAllMdxFiles(contentDir: string): string[] {
|
|
108
|
+
return getAllFiles(contentDir, ['.mdx', '.md']);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Get all page.tsx files in app directory
|
|
113
|
+
*/
|
|
114
|
+
export function getPageFiles(appDir: string): string[] {
|
|
115
|
+
const pages: string[] = [];
|
|
116
|
+
|
|
117
|
+
function scan(dir: string) {
|
|
118
|
+
if (!fs.existsSync(dir)) return;
|
|
119
|
+
|
|
120
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
121
|
+
|
|
122
|
+
for (const entry of entries) {
|
|
123
|
+
// Skip special directories
|
|
124
|
+
if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue;
|
|
125
|
+
|
|
126
|
+
const fullPath = path.join(dir, entry.name);
|
|
127
|
+
|
|
128
|
+
if (entry.isDirectory()) {
|
|
129
|
+
// Skip route groups for page detection but still scan them
|
|
130
|
+
scan(fullPath);
|
|
131
|
+
} else if (entry.name === 'page.tsx' || entry.name === 'page.ts') {
|
|
132
|
+
pages.push(fullPath);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
scan(appDir);
|
|
138
|
+
return pages;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Get file info for link resolution
|
|
143
|
+
*/
|
|
144
|
+
export function getFileInfo(filePath: string, contentDir: string): FileInfo {
|
|
145
|
+
const relativePath = path.relative(contentDir, filePath);
|
|
146
|
+
const parsed = path.parse(relativePath);
|
|
147
|
+
const isIndex = parsed.name === 'index';
|
|
148
|
+
const folder = parsed.dir || '';
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
fullPath: filePath,
|
|
152
|
+
relativePath,
|
|
153
|
+
isIndex,
|
|
154
|
+
folder,
|
|
155
|
+
name: parsed.name,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Check if a path exists as MDX file or directory with index
|
|
161
|
+
*/
|
|
162
|
+
export function pathExists(docsPath: string, contentDir: string): boolean {
|
|
163
|
+
const cleanPath = docsPath.replace(/\/$/, '').replace(/^\//, '');
|
|
164
|
+
|
|
165
|
+
const candidates = [
|
|
166
|
+
path.join(contentDir, cleanPath + '.mdx'),
|
|
167
|
+
path.join(contentDir, cleanPath + '.md'),
|
|
168
|
+
path.join(contentDir, cleanPath, 'index.mdx'),
|
|
169
|
+
path.join(contentDir, cleanPath, 'index.md'),
|
|
170
|
+
];
|
|
171
|
+
|
|
172
|
+
return candidates.some(p => fs.existsSync(p));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Find content directory, looking in common locations
|
|
177
|
+
*/
|
|
178
|
+
export function findContentDir(cwd: string): string | null {
|
|
179
|
+
const candidates = [
|
|
180
|
+
path.join(cwd, 'content'),
|
|
181
|
+
path.join(cwd, 'docs'),
|
|
182
|
+
path.join(cwd, 'pages', 'docs'),
|
|
183
|
+
];
|
|
184
|
+
|
|
185
|
+
for (const candidate of candidates) {
|
|
186
|
+
if (fs.existsSync(candidate)) {
|
|
187
|
+
return candidate;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Find app directory
|
|
196
|
+
*/
|
|
197
|
+
export function findAppDir(cwd: string): string | null {
|
|
198
|
+
const appDir = path.join(cwd, 'app');
|
|
199
|
+
return fs.existsSync(appDir) ? appDir : null;
|
|
200
|
+
}
|