@seo-partner/adapters 0.0.0-stage → 1.0.3

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 (164) hide show
  1. package/LICENSE +21 -0
  2. package/dist/ast-merger.d.ts +24 -0
  3. package/dist/ast-merger.d.ts.map +1 -0
  4. package/dist/ast-merger.js +85 -0
  5. package/dist/ast-merger.js.map +1 -0
  6. package/dist/astro-adapter.d.ts +19 -0
  7. package/dist/astro-adapter.d.ts.map +1 -0
  8. package/dist/astro-adapter.js +114 -0
  9. package/dist/astro-adapter.js.map +1 -0
  10. package/dist/blog-scaffold-generator.d.ts +8 -0
  11. package/dist/blog-scaffold-generator.d.ts.map +1 -0
  12. package/dist/blog-scaffold-generator.js +138 -0
  13. package/dist/blog-scaffold-generator.js.map +1 -0
  14. package/dist/contract.d.ts +21 -0
  15. package/dist/contract.d.ts.map +1 -0
  16. package/dist/contract.js +2 -0
  17. package/dist/contract.js.map +1 -0
  18. package/dist/detector.d.ts +15 -0
  19. package/dist/detector.d.ts.map +1 -0
  20. package/dist/detector.js +40 -0
  21. package/dist/detector.js.map +1 -0
  22. package/dist/django-adapter.d.ts +13 -0
  23. package/dist/django-adapter.d.ts.map +1 -0
  24. package/dist/django-adapter.js +133 -0
  25. package/dist/django-adapter.js.map +1 -0
  26. package/dist/index.d.ts +32 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +32 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/laravel-adapter.d.ts +7 -0
  31. package/dist/laravel-adapter.d.ts.map +1 -0
  32. package/dist/laravel-adapter.js +35 -0
  33. package/dist/laravel-adapter.js.map +1 -0
  34. package/dist/layout-title-resolver.d.ts +8 -0
  35. package/dist/layout-title-resolver.d.ts.map +1 -0
  36. package/dist/layout-title-resolver.js +28 -0
  37. package/dist/layout-title-resolver.js.map +1 -0
  38. package/dist/nextjs-adapter.d.ts +15 -0
  39. package/dist/nextjs-adapter.d.ts.map +1 -0
  40. package/dist/nextjs-adapter.js +83 -0
  41. package/dist/nextjs-adapter.js.map +1 -0
  42. package/dist/nuxt-adapter.d.ts +19 -0
  43. package/dist/nuxt-adapter.d.ts.map +1 -0
  44. package/dist/nuxt-adapter.js +108 -0
  45. package/dist/nuxt-adapter.js.map +1 -0
  46. package/dist/parsers/astro-parser.d.ts +8 -0
  47. package/dist/parsers/astro-parser.d.ts.map +1 -0
  48. package/dist/parsers/astro-parser.js +218 -0
  49. package/dist/parsers/astro-parser.js.map +1 -0
  50. package/dist/parsers/dtl-parser.d.ts +7 -0
  51. package/dist/parsers/dtl-parser.d.ts.map +1 -0
  52. package/dist/parsers/dtl-parser.js +143 -0
  53. package/dist/parsers/dtl-parser.js.map +1 -0
  54. package/dist/parsers/html-parser.d.ts +3 -0
  55. package/dist/parsers/html-parser.d.ts.map +1 -0
  56. package/dist/parsers/html-parser.js +195 -0
  57. package/dist/parsers/html-parser.js.map +1 -0
  58. package/dist/parsers/layout-resolver.d.ts +44 -0
  59. package/dist/parsers/layout-resolver.d.ts.map +1 -0
  60. package/dist/parsers/layout-resolver.js +85 -0
  61. package/dist/parsers/layout-resolver.js.map +1 -0
  62. package/dist/parsers/php-route-parser.d.ts +11 -0
  63. package/dist/parsers/php-route-parser.d.ts.map +1 -0
  64. package/dist/parsers/php-route-parser.js +194 -0
  65. package/dist/parsers/php-route-parser.js.map +1 -0
  66. package/dist/parsers/php-template-parser.d.ts +3 -0
  67. package/dist/parsers/php-template-parser.d.ts.map +1 -0
  68. package/dist/parsers/php-template-parser.js +230 -0
  69. package/dist/parsers/php-template-parser.js.map +1 -0
  70. package/dist/parsers/python-url-parser.d.ts +19 -0
  71. package/dist/parsers/python-url-parser.d.ts.map +1 -0
  72. package/dist/parsers/python-url-parser.js +69 -0
  73. package/dist/parsers/python-url-parser.js.map +1 -0
  74. package/dist/parsers/remix-parser.d.ts +3 -0
  75. package/dist/parsers/remix-parser.d.ts.map +1 -0
  76. package/dist/parsers/remix-parser.js +139 -0
  77. package/dist/parsers/remix-parser.js.map +1 -0
  78. package/dist/parsers/robots-sitemap-parser.d.ts +15 -0
  79. package/dist/parsers/robots-sitemap-parser.d.ts.map +1 -0
  80. package/dist/parsers/robots-sitemap-parser.js +100 -0
  81. package/dist/parsers/robots-sitemap-parser.js.map +1 -0
  82. package/dist/parsers/route-normalizer.d.ts +11 -0
  83. package/dist/parsers/route-normalizer.d.ts.map +1 -0
  84. package/dist/parsers/route-normalizer.js +59 -0
  85. package/dist/parsers/route-normalizer.js.map +1 -0
  86. package/dist/parsers/svelte-parser.d.ts +3 -0
  87. package/dist/parsers/svelte-parser.d.ts.map +1 -0
  88. package/dist/parsers/svelte-parser.js +148 -0
  89. package/dist/parsers/svelte-parser.js.map +1 -0
  90. package/dist/parsers/tsx-ast-parser.d.ts +4 -0
  91. package/dist/parsers/tsx-ast-parser.d.ts.map +1 -0
  92. package/dist/parsers/tsx-ast-parser.js +380 -0
  93. package/dist/parsers/tsx-ast-parser.js.map +1 -0
  94. package/dist/parsers/vue-sfc-parser.d.ts +3 -0
  95. package/dist/parsers/vue-sfc-parser.d.ts.map +1 -0
  96. package/dist/parsers/vue-sfc-parser.js +183 -0
  97. package/dist/parsers/vue-sfc-parser.js.map +1 -0
  98. package/dist/php-adapter.d.ts +12 -0
  99. package/dist/php-adapter.d.ts.map +1 -0
  100. package/dist/php-adapter.js +42 -0
  101. package/dist/php-adapter.js.map +1 -0
  102. package/dist/php-native-adapter.d.ts +7 -0
  103. package/dist/php-native-adapter.d.ts.map +1 -0
  104. package/dist/php-native-adapter.js +31 -0
  105. package/dist/php-native-adapter.js.map +1 -0
  106. package/dist/remix-adapter.d.ts +13 -0
  107. package/dist/remix-adapter.d.ts.map +1 -0
  108. package/dist/remix-adapter.js +91 -0
  109. package/dist/remix-adapter.js.map +1 -0
  110. package/dist/sitemap-builder.d.ts +10 -0
  111. package/dist/sitemap-builder.d.ts.map +1 -0
  112. package/dist/sitemap-builder.js +37 -0
  113. package/dist/sitemap-builder.js.map +1 -0
  114. package/dist/skeleton-detector.d.ts +14 -0
  115. package/dist/skeleton-detector.d.ts.map +1 -0
  116. package/dist/skeleton-detector.js +33 -0
  117. package/dist/skeleton-detector.js.map +1 -0
  118. package/dist/static-adapter.d.ts +12 -0
  119. package/dist/static-adapter.d.ts.map +1 -0
  120. package/dist/static-adapter.js +44 -0
  121. package/dist/static-adapter.js.map +1 -0
  122. package/dist/sveltekit-adapter.d.ts +19 -0
  123. package/dist/sveltekit-adapter.d.ts.map +1 -0
  124. package/dist/sveltekit-adapter.js +115 -0
  125. package/dist/sveltekit-adapter.js.map +1 -0
  126. package/dist/symfony-adapter.d.ts +7 -0
  127. package/dist/symfony-adapter.d.ts.map +1 -0
  128. package/dist/symfony-adapter.js +23 -0
  129. package/dist/symfony-adapter.js.map +1 -0
  130. package/package.json +23 -3
  131. package/src/ast-merger.ts +123 -0
  132. package/src/astro-adapter.ts +155 -0
  133. package/src/blog-scaffold-generator.ts +153 -0
  134. package/src/contract.ts +40 -0
  135. package/src/detector.ts +46 -0
  136. package/src/django-adapter.ts +173 -0
  137. package/src/index.ts +31 -0
  138. package/src/laravel-adapter.ts +45 -0
  139. package/src/layout-title-resolver.ts +37 -0
  140. package/src/nextjs-adapter.ts +116 -0
  141. package/src/nuxt-adapter.ts +148 -0
  142. package/src/parsers/astro-parser.ts +249 -0
  143. package/src/parsers/dtl-parser.ts +166 -0
  144. package/src/parsers/html-parser.ts +214 -0
  145. package/src/parsers/layout-resolver.ts +112 -0
  146. package/src/parsers/php-route-parser.ts +254 -0
  147. package/src/parsers/php-template-parser.ts +303 -0
  148. package/src/parsers/python-url-parser.ts +92 -0
  149. package/src/parsers/remix-parser.ts +161 -0
  150. package/src/parsers/robots-sitemap-parser.ts +128 -0
  151. package/src/parsers/route-normalizer.ts +70 -0
  152. package/src/parsers/svelte-parser.ts +167 -0
  153. package/src/parsers/tsx-ast-parser.ts +404 -0
  154. package/src/parsers/vue-sfc-parser.ts +207 -0
  155. package/src/php-adapter.ts +68 -0
  156. package/src/php-native-adapter.ts +46 -0
  157. package/src/remix-adapter.ts +123 -0
  158. package/src/sitemap-builder.ts +57 -0
  159. package/src/skeleton-detector.ts +56 -0
  160. package/src/static-adapter.ts +62 -0
  161. package/src/sveltekit-adapter.ts +160 -0
  162. package/src/symfony-adapter.ts +33 -0
  163. package/tsconfig.json +9 -0
  164. package/README.md +0 -3
@@ -0,0 +1,254 @@
1
+ export type PhpFlavor = 'laravel' | 'symfony' | 'php';
2
+
3
+ export interface DiscoveredPhpRouteEntry {
4
+ readonly pattern: string;
5
+ readonly filePath: string;
6
+ readonly flavor: PhpFlavor;
7
+ }
8
+
9
+ export function detectPhpFlavor(
10
+ filePaths: ReadonlyArray<string>,
11
+ contentsByPath: ReadonlyMap<string, string>,
12
+ ): PhpFlavor | null {
13
+ const composerJson = contentsByPath.get('composer.json') ?? '';
14
+ if (
15
+ composerJson.includes('laravel/framework') ||
16
+ filePaths.includes('artisan') ||
17
+ filePaths.some((p) => p.endsWith('.blade.php'))
18
+ ) {
19
+ return 'laravel';
20
+ }
21
+
22
+ if (
23
+ composerJson.includes('symfony/framework-bundle') ||
24
+ composerJson.includes('symfony/twig-bundle') ||
25
+ filePaths.some((p) => p.endsWith('.html.twig'))
26
+ ) {
27
+ return 'symfony';
28
+ }
29
+
30
+ if (filePaths.some((p) => p.endsWith('.php'))) {
31
+ return 'php';
32
+ }
33
+
34
+ return null;
35
+ }
36
+
37
+ function normalizeRoutePattern(rawPattern: string): string {
38
+ const trimmed = rawPattern.trim();
39
+ if (!trimmed || trimmed === '/') return '/';
40
+ const withSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
41
+ return withSlash.length > 1 && withSlash.endsWith('/')
42
+ ? withSlash.slice(0, -1)
43
+ : withSlash;
44
+ }
45
+
46
+ export function parseLaravelRoutes(
47
+ filePaths: ReadonlyArray<string>,
48
+ contentsByPath: ReadonlyMap<string, string>,
49
+ ): ReadonlyArray<DiscoveredPhpRouteEntry> {
50
+ const viewPathByDotName = new Map<string, string>();
51
+ for (const fp of filePaths) {
52
+ if (fp.startsWith('resources/views/') && fp.endsWith('.blade.php')) {
53
+ const rel = fp
54
+ .slice('resources/views/'.length)
55
+ .slice(0, -'.blade.php'.length);
56
+ const dotName = rel.replace(/\//g, '.');
57
+ viewPathByDotName.set(dotName, fp);
58
+ viewPathByDotName.set(rel, fp);
59
+ }
60
+ }
61
+
62
+ const results: DiscoveredPhpRouteEntry[] = [];
63
+ const mappedFiles = new Set<string>();
64
+ const webPhp = contentsByPath.get('routes/web.php') ?? '';
65
+
66
+ if (webPhp) {
67
+ // 1. Route::view('/uri', 'view.name')
68
+ const routeViewRegex =
69
+ /Route::view\s*\(\s*['"]([^'"]+)['"]\s*,\s*['"]([^'"]+)['"]/g;
70
+ let m: RegExpExecArray | null;
71
+ while ((m = routeViewRegex.exec(webPhp)) !== null) {
72
+ const pattern = normalizeRoutePattern(m[1] ?? '/');
73
+ const viewName = m[2] ?? '';
74
+ const filePath = viewPathByDotName.get(viewName);
75
+ if (filePath) {
76
+ results.push({ pattern, filePath, flavor: 'laravel' });
77
+ mappedFiles.add(filePath);
78
+ }
79
+ }
80
+
81
+ // 2. Route::get('/uri', function() { return view('view.name'); })
82
+ const routeClosureRegex =
83
+ /Route::(?:get|any)\s*\(\s*['"]([^'"]+)['"][\s\S]*?view\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
84
+ while ((m = routeClosureRegex.exec(webPhp)) !== null) {
85
+ const pattern = normalizeRoutePattern(m[1] ?? '/');
86
+ const viewName = m[2] ?? '';
87
+ const filePath = viewPathByDotName.get(viewName);
88
+ if (filePath && !mappedFiles.has(filePath)) {
89
+ results.push({ pattern, filePath, flavor: 'laravel' });
90
+ mappedFiles.add(filePath);
91
+ }
92
+ }
93
+
94
+ // 3. Controller-based routes: Route::get('/uri', [Controller::class, 'method'])
95
+ const routeControllerRegex =
96
+ /Route::(?:get|any)\s*\(\s*['"]([^'"]+)['"]\s*,\s*(?:\[|\w+Controller)/g;
97
+ while ((m = routeControllerRegex.exec(webPhp)) !== null) {
98
+ const pattern = normalizeRoutePattern(m[1] ?? '/');
99
+ if (!results.some((r) => r.pattern === pattern)) {
100
+ results.push({ pattern, filePath: 'routes/web.php', flavor: 'laravel' });
101
+ }
102
+ }
103
+ }
104
+
105
+ // Fallback for any public Blade view not explicitly matched in routes/web.php
106
+ for (const [dotName, filePath] of viewPathByDotName.entries()) {
107
+ if (mappedFiles.has(filePath) || dotName.includes('/')) continue;
108
+ if (
109
+ dotName.startsWith('layouts.') ||
110
+ dotName.startsWith('components.') ||
111
+ dotName.startsWith('partials.') ||
112
+ dotName.startsWith('emails.') ||
113
+ dotName.startsWith('errors.') ||
114
+ dotName.startsWith('vendor.')
115
+ ) {
116
+ continue;
117
+ }
118
+ const pattern =
119
+ dotName === 'welcome' || dotName === 'home' || dotName === 'index'
120
+ ? '/'
121
+ : `/${dotName.replace(/\.index$/, '').replace(/\./g, '/')}`;
122
+ results.push({
123
+ pattern: normalizeRoutePattern(pattern),
124
+ filePath,
125
+ flavor: 'laravel',
126
+ });
127
+ mappedFiles.add(filePath);
128
+ }
129
+
130
+ return results;
131
+ }
132
+
133
+ export function parseSymfonyRoutes(
134
+ filePaths: ReadonlyArray<string>,
135
+ contentsByPath: ReadonlyMap<string, string>,
136
+ ): ReadonlyArray<DiscoveredPhpRouteEntry> {
137
+ const twigTemplates = new Set<string>(
138
+ filePaths.filter((p) => p.startsWith('templates/') && p.endsWith('.html.twig')),
139
+ );
140
+
141
+ const results: DiscoveredPhpRouteEntry[] = [];
142
+ const mappedFiles = new Set<string>();
143
+
144
+ // 1. Parse PHP 8 #[Route('/path')] attributes in src/Controller/**/*.php
145
+ for (const fp of filePaths) {
146
+ if (!fp.startsWith('src/Controller/') || !fp.endsWith('.php')) continue;
147
+ const content = contentsByPath.get(fp) ?? '';
148
+ const attrRegex =
149
+ /#\[Route\s*\(\s*(?:path:\s*)?['"]([^'"]+)['"][^\]]*\)\][\s\S]*?\$this->render\s*\(\s*['"]([^'"]+)['"]/g;
150
+ let m: RegExpExecArray | null;
151
+ while ((m = attrRegex.exec(content)) !== null) {
152
+ const pattern = normalizeRoutePattern(m[1] ?? '/');
153
+ const relTwig = (m[2] ?? '').replace(/^\/+/, '');
154
+ const fullTwig = `templates/${relTwig}`;
155
+ if (twigTemplates.has(fullTwig)) {
156
+ results.push({ pattern, filePath: fullTwig, flavor: 'symfony' });
157
+ mappedFiles.add(fullTwig);
158
+ }
159
+ }
160
+ }
161
+
162
+ // 2. Parse config/routes.yaml
163
+ const routesYaml = contentsByPath.get('config/routes.yaml') ?? '';
164
+ if (routesYaml) {
165
+ const blocks = routesYaml.split(/\r?\n(?=[a-zA-Z0-9_-]+:\s*$)/m);
166
+ for (const block of blocks) {
167
+ const pathMatch = /\bpath:\s*['"]?([^\s'"]+)['"]?/.exec(block);
168
+ const ctrlMatch =
169
+ /\bcontroller:\s*[\w\\]+\\([A-Za-z0-9_]+)Controller::([a-zA-Z0-9_]+)/.exec(
170
+ block,
171
+ );
172
+ if (pathMatch?.[1] && ctrlMatch?.[1] && ctrlMatch[2]) {
173
+ const pattern = normalizeRoutePattern(pathMatch[1]);
174
+ const folder = ctrlMatch[1].toLowerCase();
175
+ const action = ctrlMatch[2].toLowerCase();
176
+ const candidate = `templates/${folder}/${action}.html.twig`;
177
+ if (twigTemplates.has(candidate) && !mappedFiles.has(candidate)) {
178
+ results.push({ pattern, filePath: candidate, flavor: 'symfony' });
179
+ mappedFiles.add(candidate);
180
+ }
181
+ }
182
+ }
183
+ }
184
+
185
+ // 3. Fallback for unmapped public templates
186
+ for (const twigPath of twigTemplates) {
187
+ if (mappedFiles.has(twigPath)) continue;
188
+ const rel = twigPath.slice('templates/'.length, -'.html.twig'.length);
189
+ if (
190
+ rel === 'base' ||
191
+ rel.startsWith('layouts/') ||
192
+ rel.startsWith('partials/') ||
193
+ rel.startsWith('components/') ||
194
+ rel.startsWith('bundles/')
195
+ ) {
196
+ continue;
197
+ }
198
+ const cleaned = rel.replace(/\/index$/, '');
199
+ const pattern =
200
+ cleaned === 'home' || cleaned === 'index' || cleaned === 'default'
201
+ ? '/'
202
+ : normalizeRoutePattern(cleaned);
203
+ results.push({ pattern, filePath: twigPath, flavor: 'symfony' });
204
+ mappedFiles.add(twigPath);
205
+ }
206
+
207
+ return results;
208
+ }
209
+
210
+ export function discoverNativePhpRoutes(
211
+ filePaths: ReadonlyArray<string>,
212
+ ): ReadonlyArray<DiscoveredPhpRouteEntry> {
213
+ const results: DiscoveredPhpRouteEntry[] = [];
214
+ const publicPhpFiles = filePaths.filter(
215
+ (p) => p.startsWith('public/') && p.endsWith('.php'),
216
+ );
217
+
218
+ if (publicPhpFiles.length > 0) {
219
+ for (const fp of publicPhpFiles) {
220
+ const rel = fp.slice('public/'.length);
221
+ const pattern =
222
+ rel === 'index.php' ? '/' : normalizeRoutePattern(rel);
223
+ results.push({ pattern, filePath: fp, flavor: 'php' });
224
+ }
225
+ return results;
226
+ }
227
+
228
+ for (const fp of filePaths) {
229
+ if (!fp.endsWith('.php')) continue;
230
+ const lower = fp.toLowerCase();
231
+ if (
232
+ lower === 'artisan' ||
233
+ lower.startsWith('includes/') ||
234
+ lower.startsWith('inc/') ||
235
+ lower.startsWith('config/') ||
236
+ lower.startsWith('vendor/') ||
237
+ lower.startsWith('routes/') ||
238
+ lower.startsWith('src/') ||
239
+ lower.startsWith('app/') ||
240
+ lower.startsWith('tests/') ||
241
+ lower.endsWith('header.php') ||
242
+ lower.endsWith('footer.php') ||
243
+ lower.endsWith('functions.php')
244
+ ) {
245
+ continue;
246
+ }
247
+ const pattern =
248
+ fp === 'index.php' ? '/' : normalizeRoutePattern(fp);
249
+ results.push({ pattern, filePath: fp, flavor: 'php' });
250
+ }
251
+
252
+ return results;
253
+ }
254
+
@@ -0,0 +1,303 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import type { DiscoveredRoute, ExtractedRouteData } from '@seo-partner/core';
4
+ import { parseStaticHtmlOrMarkdown } from './html-parser.js';
5
+
6
+ function lineNumberAtOffset(content: string, offset: number): number {
7
+ let line = 1;
8
+ for (let i = 0; i < offset && i < content.length; i += 1) {
9
+ if (content.charCodeAt(i) === 10) line += 1;
10
+ }
11
+ return line;
12
+ }
13
+
14
+ function readProjectFileSafe(projectRoot: string, relOrAbsPath: string): string | null {
15
+ try {
16
+ const full = path.isAbsolute(relOrAbsPath)
17
+ ? relOrAbsPath
18
+ : path.join(projectRoot, relOrAbsPath);
19
+ if (fs.existsSync(full) && fs.statSync(full).isFile()) {
20
+ return fs.readFileSync(full, 'utf-8');
21
+ }
22
+ return null;
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ function extractBladeMetadata(
29
+ projectRoot: string,
30
+ route: DiscoveredRoute,
31
+ fileContent: string,
32
+ ): ExtractedRouteData {
33
+ const base = parseStaticHtmlOrMarkdown(
34
+ { ...route, routerKind: 'static-html' },
35
+ fileContent,
36
+ );
37
+
38
+ let title = base.title;
39
+ let description = base.description;
40
+ let canonical = base.canonical;
41
+ let robots = base.robots;
42
+ let htmlLang = base.htmlLang;
43
+
44
+ // 1. Check @section('title', '...') or @section('title')...@endsection
45
+ const inlineTitleMatch =
46
+ /@section\s*\(\s*['"]title['"]\s*,\s*['"]([^'"]*)['"]\s*\)/i.exec(fileContent);
47
+ if (inlineTitleMatch) {
48
+ title = {
49
+ value: (inlineTitleMatch[1] ?? '').trim(),
50
+ line: lineNumberAtOffset(fileContent, inlineTitleMatch.index),
51
+ };
52
+ } else {
53
+ const blockTitleMatch =
54
+ /@section\s*\(\s*['"]title['"]\s*\)([\s\S]*?)@(?:endsection|stop)/i.exec(
55
+ fileContent,
56
+ );
57
+ if (blockTitleMatch) {
58
+ title = {
59
+ value: (blockTitleMatch[1] ?? '').replace(/<[^>]+>/g, '').trim(),
60
+ line: lineNumberAtOffset(fileContent, blockTitleMatch.index),
61
+ };
62
+ }
63
+ }
64
+
65
+ // 2. Check @section('description', '...')
66
+ const inlineDescMatch =
67
+ /@section\s*\(\s*['"](?:description|meta_description)['"]\s*,\s*['"]([^'"]*)['"]\s*\)/i.exec(
68
+ fileContent,
69
+ );
70
+ if (inlineDescMatch) {
71
+ description = {
72
+ value: (inlineDescMatch[1] ?? '').trim(),
73
+ line: lineNumberAtOffset(fileContent, inlineDescMatch.index),
74
+ };
75
+ }
76
+
77
+ // 3. Resolve @extends('layouts.app')
78
+ const extendsMatch = /@extends\s*\(\s*['"]([^'"]+)['"]\s*\)/i.exec(fileContent);
79
+ if (extendsMatch?.[1]) {
80
+ const layoutRel = `resources/views/${extendsMatch[1].replace(/\./g, '/')}.blade.php`;
81
+ const layoutContent = readProjectFileSafe(projectRoot, layoutRel);
82
+ if (layoutContent) {
83
+ const layoutParsed = parseStaticHtmlOrMarkdown(
84
+ { ...route, filePath: layoutRel, routerKind: 'static-html' },
85
+ layoutContent,
86
+ );
87
+ if (htmlLang === undefined && layoutParsed.htmlLang !== undefined) {
88
+ htmlLang = layoutParsed.htmlLang;
89
+ }
90
+ if (!title) {
91
+ const yieldTitleDefault =
92
+ /@yield\s*\(\s*['"]title['"]\s*,\s*['"]([^'"]+)['"]\s*\)/i.exec(
93
+ layoutContent,
94
+ );
95
+ if (yieldTitleDefault?.[1]) {
96
+ title = {
97
+ value: yieldTitleDefault[1].trim(),
98
+ line: lineNumberAtOffset(layoutContent, yieldTitleDefault.index),
99
+ };
100
+ } else if (
101
+ layoutParsed.title &&
102
+ !layoutParsed.title.value.includes('@yield')
103
+ ) {
104
+ title = layoutParsed.title;
105
+ }
106
+ }
107
+ if (!description) {
108
+ const yieldDescDefault =
109
+ /@yield\s*\(\s*['"]description['"]\s*,\s*['"]([^'"]+)['"]\s*\)/i.exec(
110
+ layoutContent,
111
+ );
112
+ if (yieldDescDefault?.[1] && yieldDescDefault[1].trim().length > 0) {
113
+ description = {
114
+ value: yieldDescDefault[1].trim(),
115
+ line: lineNumberAtOffset(layoutContent, yieldDescDefault.index),
116
+ };
117
+ } else if (
118
+ layoutParsed.description &&
119
+ !layoutParsed.description.value.includes('@yield') &&
120
+ layoutParsed.description.value.trim().length > 0
121
+ ) {
122
+ description = layoutParsed.description;
123
+ }
124
+ }
125
+ if (!canonical && layoutParsed.canonical) {
126
+ canonical = layoutParsed.canonical;
127
+ }
128
+ if (!robots && layoutParsed.robots) {
129
+ robots = layoutParsed.robots;
130
+ }
131
+ }
132
+ }
133
+
134
+ return {
135
+ ...base,
136
+ route,
137
+ title,
138
+ description,
139
+ canonical,
140
+ robots,
141
+ ...(htmlLang !== undefined ? { htmlLang } : {}),
142
+ };
143
+ }
144
+
145
+ function extractTwigMetadata(
146
+ projectRoot: string,
147
+ route: DiscoveredRoute,
148
+ fileContent: string,
149
+ ): ExtractedRouteData {
150
+ const base = parseStaticHtmlOrMarkdown(
151
+ { ...route, routerKind: 'static-html' },
152
+ fileContent,
153
+ );
154
+
155
+ let title = base.title;
156
+ let description = base.description;
157
+ let canonical = base.canonical;
158
+ let robots = base.robots;
159
+ let htmlLang = base.htmlLang;
160
+
161
+ // 1. Check {% block title %}...{% endblock %}
162
+ const blockTitleMatch =
163
+ /\{%\s*block\s+title\s*%\}[\s\S]*?\{%\s*endblock(?:\s+title)?\s*%\}/i.exec(
164
+ fileContent,
165
+ );
166
+ if (blockTitleMatch) {
167
+ const inner = blockTitleMatch[0]
168
+ .replace(/^\{%\s*block\s+title\s*%\}/i, '')
169
+ .replace(/\{%\s*endblock(?:\s+title)?\s*%\}$/i, '')
170
+ .replace(/<[^>]+>/g, '')
171
+ .trim();
172
+ if (inner) {
173
+ title = {
174
+ value: inner,
175
+ line: lineNumberAtOffset(fileContent, blockTitleMatch.index),
176
+ };
177
+ }
178
+ }
179
+
180
+ // 2. Resolve {% extends 'base.html.twig' %}
181
+ const extendsMatch = /\{%\s*extends\s+['"]([^'"]+)['"]\s*%\}/i.exec(fileContent);
182
+ if (extendsMatch?.[1]) {
183
+ const parentRel = `templates/${extendsMatch[1].replace(/^\/+/, '')}`;
184
+ const parentContent = readProjectFileSafe(projectRoot, parentRel);
185
+ if (parentContent) {
186
+ const parentParsed = parseStaticHtmlOrMarkdown(
187
+ { ...route, filePath: parentRel, routerKind: 'static-html' },
188
+ parentContent,
189
+ );
190
+ if (htmlLang === undefined && parentParsed.htmlLang !== undefined) {
191
+ htmlLang = parentParsed.htmlLang;
192
+ }
193
+ if (!title && parentParsed.title) {
194
+ const cleanedParentTitle = parentParsed.title.value
195
+ .replace(/\{%\s*block\s+title\s*%\}/i, '')
196
+ .replace(/\{%\s*endblock(?:\s+title)?\s*%\}/i, '')
197
+ .trim();
198
+ if (cleanedParentTitle) {
199
+ title = {
200
+ value: cleanedParentTitle,
201
+ line: parentParsed.title.line,
202
+ };
203
+ }
204
+ }
205
+ if (!description && parentParsed.description) {
206
+ description = parentParsed.description;
207
+ }
208
+ if (!canonical && parentParsed.canonical) {
209
+ canonical = parentParsed.canonical;
210
+ }
211
+ }
212
+ }
213
+
214
+ return {
215
+ ...base,
216
+ route,
217
+ title,
218
+ description,
219
+ canonical,
220
+ robots,
221
+ ...(htmlLang !== undefined ? { htmlLang } : {}),
222
+ };
223
+ }
224
+
225
+ function extractNativePhpMetadata(
226
+ projectRoot: string,
227
+ route: DiscoveredRoute,
228
+ fileContent: string,
229
+ ): ExtractedRouteData {
230
+ const base = parseStaticHtmlOrMarkdown(
231
+ { ...route, routerKind: 'static-html' },
232
+ fileContent,
233
+ );
234
+
235
+ let title = base.title;
236
+ let description = base.description;
237
+ let canonical = base.canonical;
238
+ let robots = base.robots;
239
+ let htmlLang = base.htmlLang;
240
+
241
+ // Check for PHP include/require of a header file (e.g. require_once __DIR__ . '/../includes/header.php';)
242
+ const includeRegex =
243
+ /\b(?:include|include_once|require|require_once)\b[^;]*?['"]([^'"]+\.php)['"]/gi;
244
+ let m: RegExpExecArray | null;
245
+ const currentFileDir = path.dirname(path.join(projectRoot, route.filePath));
246
+
247
+ while ((m = includeRegex.exec(fileContent)) !== null) {
248
+ const rawInc = (m[1] ?? '').replace(/^\/+/, '');
249
+ const candidateRelToDir = path.resolve(currentFileDir, rawInc);
250
+ const candidateRelToRoot = path.resolve(projectRoot, rawInc);
251
+
252
+ const incContent =
253
+ readProjectFileSafe(projectRoot, candidateRelToDir) ??
254
+ readProjectFileSafe(projectRoot, candidateRelToRoot);
255
+
256
+ if (incContent) {
257
+ const incParsed = parseStaticHtmlOrMarkdown(
258
+ { ...route, routerKind: 'static-html' },
259
+ incContent,
260
+ );
261
+ if (htmlLang === undefined && incParsed.htmlLang !== undefined) {
262
+ htmlLang = incParsed.htmlLang;
263
+ }
264
+ if (!title && incParsed.title) {
265
+ title = incParsed.title;
266
+ }
267
+ if (!description && incParsed.description) {
268
+ description = incParsed.description;
269
+ }
270
+ if (!canonical && incParsed.canonical) {
271
+ canonical = incParsed.canonical;
272
+ }
273
+ if (!robots && incParsed.robots) {
274
+ robots = incParsed.robots;
275
+ }
276
+ }
277
+ }
278
+
279
+ return {
280
+ ...base,
281
+ route,
282
+ title,
283
+ description,
284
+ canonical,
285
+ robots,
286
+ ...(htmlLang !== undefined ? { htmlLang } : {}),
287
+ };
288
+ }
289
+
290
+ export function parsePhpOrTemplateFile(
291
+ projectRoot: string,
292
+ route: DiscoveredRoute,
293
+ fileContent: string,
294
+ ): ExtractedRouteData {
295
+ if (route.filePath.endsWith('.blade.php') || route.routerKind === 'laravel') {
296
+ return extractBladeMetadata(projectRoot, route, fileContent);
297
+ }
298
+ if (route.filePath.endsWith('.html.twig') || route.routerKind === 'symfony') {
299
+ return extractTwigMetadata(projectRoot, route, fileContent);
300
+ }
301
+ return extractNativePhpMetadata(projectRoot, route, fileContent);
302
+ }
303
+
@@ -0,0 +1,92 @@
1
+ import { normalizeRoutePattern } from './route-normalizer.js';
2
+
3
+ export interface DjangoDiscoveredPattern {
4
+ readonly pattern: string;
5
+ readonly rawPattern: string;
6
+ readonly viewName: string;
7
+ readonly lineNumber: number;
8
+ }
9
+
10
+ export interface DjangoViewTemplateMapping {
11
+ readonly viewName: string;
12
+ readonly templateName: string;
13
+ }
14
+
15
+ function lineNumberAtOffset(content: string, offset: number): number {
16
+ let line = 1;
17
+ for (let i = 0; i < offset && i < content.length; i += 1) {
18
+ if (content.charCodeAt(i) === 10) line += 1;
19
+ }
20
+ return line;
21
+ }
22
+
23
+ /**
24
+ * Statically parses a Django urls.py file to extract route declarations without executing Python.
25
+ */
26
+ export function parseDjangoUrls(urlsPyContent: string): ReadonlyArray<DjangoDiscoveredPattern> {
27
+ const routes: DjangoDiscoveredPattern[] = [];
28
+
29
+ // Match path('pattern', view, ...) or re_path(r'pattern', view, ...)
30
+ const pathRegex = /(?:path|re_path)\s*\(\s*(?:r?['"]([^'"]*)['"])\s*,\s*([a-zA-Z0-9_.]+(?:\.as_view\(\))?)/g;
31
+ let match: RegExpExecArray | null;
32
+
33
+ while ((match = pathRegex.exec(urlsPyContent)) !== null) {
34
+ const rawPattern = match[1] ?? '';
35
+ const rawView = match[2] ?? '';
36
+ const line = lineNumberAtOffset(urlsPyContent, match.index);
37
+
38
+ // Clean view identifier (e.g. views.home_view -> home_view, views.ShopView.as_view() -> ShopView)
39
+ let viewName = rawView.replace(/\.as_view\(\)$/, '');
40
+ if (viewName.includes('.')) {
41
+ viewName = viewName.split('.').pop() ?? viewName;
42
+ }
43
+
44
+ const pattern = normalizeRoutePattern(rawPattern, 'django');
45
+
46
+ routes.push({
47
+ pattern,
48
+ rawPattern,
49
+ viewName,
50
+ lineNumber: line,
51
+ });
52
+ }
53
+
54
+ return routes;
55
+ }
56
+
57
+ /**
58
+ * Statically parses a Django views.py file to extract view -> template_name mappings.
59
+ */
60
+ export function parseDjangoViews(viewsPyContent: string): ReadonlyArray<DjangoViewTemplateMapping> {
61
+ const mappings: DjangoViewTemplateMapping[] = [];
62
+
63
+ // 1. Function-based views: def view_name(request, ...): ... render(request, 'template.html', ...)
64
+ const funcRegex = /def\s+([a-zA-Z0-9_]+)\s*\([^)]*\)\s*:([\s\S]*?)(?=\ndef|\nclass|$)/g;
65
+ let funcMatch: RegExpExecArray | null;
66
+ while ((funcMatch = funcRegex.exec(viewsPyContent)) !== null) {
67
+ const viewName = funcMatch[1] ?? '';
68
+ const body = funcMatch[2] ?? '';
69
+ const renderMatch = /render\s*\(\s*[^,]+,\s*['"]([^'"]+\.html)['"]/i.exec(body);
70
+ if (renderMatch && renderMatch[1]) {
71
+ mappings.push({
72
+ viewName,
73
+ templateName: renderMatch[1],
74
+ });
75
+ }
76
+ }
77
+
78
+ // 2. Class-based views: class ViewName(...): ... template_name = 'template.html'
79
+ const classRegex = /class\s+([a-zA-Z0-9_]+)\b[\s\S]*?template_name\s*=\s*['"]([^'"]+\.html)['"]/g;
80
+ let classMatch: RegExpExecArray | null;
81
+ while ((classMatch = classRegex.exec(viewsPyContent)) !== null) {
82
+ const viewName = classMatch[1] ?? '';
83
+ const tpl = classMatch[2] ?? '';
84
+ mappings.push({
85
+ viewName,
86
+ templateName: tpl,
87
+ });
88
+ }
89
+
90
+ return mappings;
91
+ }
92
+