@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.
Files changed (68) hide show
  1. package/README.md +192 -0
  2. package/dist/cli.d.ts +1 -0
  3. package/dist/cli.mjs +3780 -0
  4. package/dist/cli.mjs.map +1 -0
  5. package/dist/crawler/index.d.ts +88 -0
  6. package/dist/crawler/index.mjs +610 -0
  7. package/dist/crawler/index.mjs.map +1 -0
  8. package/dist/google-console/index.d.ts +95 -0
  9. package/dist/google-console/index.mjs +539 -0
  10. package/dist/google-console/index.mjs.map +1 -0
  11. package/dist/index.d.ts +285 -0
  12. package/dist/index.mjs +3236 -0
  13. package/dist/index.mjs.map +1 -0
  14. package/dist/link-checker/index.d.ts +76 -0
  15. package/dist/link-checker/index.mjs +326 -0
  16. package/dist/link-checker/index.mjs.map +1 -0
  17. package/dist/markdown-report-B3QdDzxE.d.ts +193 -0
  18. package/dist/reports/index.d.ts +24 -0
  19. package/dist/reports/index.mjs +836 -0
  20. package/dist/reports/index.mjs.map +1 -0
  21. package/dist/routes/index.d.ts +69 -0
  22. package/dist/routes/index.mjs +372 -0
  23. package/dist/routes/index.mjs.map +1 -0
  24. package/dist/scanner-Cz4Th2Pt.d.ts +60 -0
  25. package/dist/types/index.d.ts +144 -0
  26. package/dist/types/index.mjs +3 -0
  27. package/dist/types/index.mjs.map +1 -0
  28. package/package.json +114 -0
  29. package/src/analyzer.ts +256 -0
  30. package/src/cli/commands/audit.ts +260 -0
  31. package/src/cli/commands/content.ts +180 -0
  32. package/src/cli/commands/crawl.ts +32 -0
  33. package/src/cli/commands/index.ts +12 -0
  34. package/src/cli/commands/inspect.ts +60 -0
  35. package/src/cli/commands/links.ts +41 -0
  36. package/src/cli/commands/robots.ts +36 -0
  37. package/src/cli/commands/routes.ts +126 -0
  38. package/src/cli/commands/sitemap.ts +48 -0
  39. package/src/cli/index.ts +149 -0
  40. package/src/cli/types.ts +40 -0
  41. package/src/config.ts +207 -0
  42. package/src/content/index.ts +51 -0
  43. package/src/content/link-checker.ts +182 -0
  44. package/src/content/link-fixer.ts +188 -0
  45. package/src/content/scanner.ts +200 -0
  46. package/src/content/sitemap-generator.ts +321 -0
  47. package/src/content/types.ts +140 -0
  48. package/src/crawler/crawler.ts +425 -0
  49. package/src/crawler/index.ts +10 -0
  50. package/src/crawler/robots-parser.ts +171 -0
  51. package/src/crawler/sitemap-validator.ts +204 -0
  52. package/src/google-console/analyzer.ts +317 -0
  53. package/src/google-console/auth.ts +100 -0
  54. package/src/google-console/client.ts +281 -0
  55. package/src/google-console/index.ts +9 -0
  56. package/src/index.ts +144 -0
  57. package/src/link-checker/index.ts +461 -0
  58. package/src/reports/claude-context.ts +149 -0
  59. package/src/reports/generator.ts +244 -0
  60. package/src/reports/index.ts +27 -0
  61. package/src/reports/json-report.ts +320 -0
  62. package/src/reports/markdown-report.ts +246 -0
  63. package/src/reports/split-report.ts +252 -0
  64. package/src/routes/analyzer.ts +324 -0
  65. package/src/routes/index.ts +25 -0
  66. package/src/routes/scanner.ts +298 -0
  67. package/src/types/index.ts +222 -0
  68. 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
+ }