@seo-partner/adapters 0.0.0-stage → 1.0.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.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/dist/astro-adapter.d.ts +13 -0
  3. package/dist/astro-adapter.d.ts.map +1 -0
  4. package/dist/astro-adapter.js +95 -0
  5. package/dist/astro-adapter.js.map +1 -0
  6. package/dist/contract.d.ts +21 -0
  7. package/dist/contract.d.ts.map +1 -0
  8. package/dist/contract.js +2 -0
  9. package/dist/contract.js.map +1 -0
  10. package/dist/detector.d.ts +15 -0
  11. package/dist/detector.d.ts.map +1 -0
  12. package/dist/detector.js +40 -0
  13. package/dist/detector.js.map +1 -0
  14. package/dist/django-adapter.d.ts +13 -0
  15. package/dist/django-adapter.d.ts.map +1 -0
  16. package/dist/django-adapter.js +133 -0
  17. package/dist/django-adapter.js.map +1 -0
  18. package/dist/index.d.ts +24 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +24 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/nextjs-adapter.d.ts +15 -0
  23. package/dist/nextjs-adapter.d.ts.map +1 -0
  24. package/dist/nextjs-adapter.js +83 -0
  25. package/dist/nextjs-adapter.js.map +1 -0
  26. package/dist/nuxt-adapter.d.ts +13 -0
  27. package/dist/nuxt-adapter.d.ts.map +1 -0
  28. package/dist/nuxt-adapter.js +88 -0
  29. package/dist/nuxt-adapter.js.map +1 -0
  30. package/dist/parsers/astro-parser.d.ts +8 -0
  31. package/dist/parsers/astro-parser.d.ts.map +1 -0
  32. package/dist/parsers/astro-parser.js +218 -0
  33. package/dist/parsers/astro-parser.js.map +1 -0
  34. package/dist/parsers/dtl-parser.d.ts +7 -0
  35. package/dist/parsers/dtl-parser.d.ts.map +1 -0
  36. package/dist/parsers/dtl-parser.js +143 -0
  37. package/dist/parsers/dtl-parser.js.map +1 -0
  38. package/dist/parsers/html-parser.d.ts +3 -0
  39. package/dist/parsers/html-parser.d.ts.map +1 -0
  40. package/dist/parsers/html-parser.js +195 -0
  41. package/dist/parsers/html-parser.js.map +1 -0
  42. package/dist/parsers/layout-resolver.d.ts +44 -0
  43. package/dist/parsers/layout-resolver.d.ts.map +1 -0
  44. package/dist/parsers/layout-resolver.js +85 -0
  45. package/dist/parsers/layout-resolver.js.map +1 -0
  46. package/dist/parsers/php-route-parser.d.ts +11 -0
  47. package/dist/parsers/php-route-parser.d.ts.map +1 -0
  48. package/dist/parsers/php-route-parser.js +194 -0
  49. package/dist/parsers/php-route-parser.js.map +1 -0
  50. package/dist/parsers/php-template-parser.d.ts +3 -0
  51. package/dist/parsers/php-template-parser.d.ts.map +1 -0
  52. package/dist/parsers/php-template-parser.js +230 -0
  53. package/dist/parsers/php-template-parser.js.map +1 -0
  54. package/dist/parsers/python-url-parser.d.ts +19 -0
  55. package/dist/parsers/python-url-parser.d.ts.map +1 -0
  56. package/dist/parsers/python-url-parser.js +69 -0
  57. package/dist/parsers/python-url-parser.js.map +1 -0
  58. package/dist/parsers/remix-parser.d.ts +3 -0
  59. package/dist/parsers/remix-parser.d.ts.map +1 -0
  60. package/dist/parsers/remix-parser.js +139 -0
  61. package/dist/parsers/remix-parser.js.map +1 -0
  62. package/dist/parsers/robots-sitemap-parser.d.ts +15 -0
  63. package/dist/parsers/robots-sitemap-parser.d.ts.map +1 -0
  64. package/dist/parsers/robots-sitemap-parser.js +100 -0
  65. package/dist/parsers/robots-sitemap-parser.js.map +1 -0
  66. package/dist/parsers/route-normalizer.d.ts +11 -0
  67. package/dist/parsers/route-normalizer.d.ts.map +1 -0
  68. package/dist/parsers/route-normalizer.js +59 -0
  69. package/dist/parsers/route-normalizer.js.map +1 -0
  70. package/dist/parsers/svelte-parser.d.ts +3 -0
  71. package/dist/parsers/svelte-parser.d.ts.map +1 -0
  72. package/dist/parsers/svelte-parser.js +148 -0
  73. package/dist/parsers/svelte-parser.js.map +1 -0
  74. package/dist/parsers/tsx-ast-parser.d.ts +4 -0
  75. package/dist/parsers/tsx-ast-parser.d.ts.map +1 -0
  76. package/dist/parsers/tsx-ast-parser.js +380 -0
  77. package/dist/parsers/tsx-ast-parser.js.map +1 -0
  78. package/dist/parsers/vue-sfc-parser.d.ts +3 -0
  79. package/dist/parsers/vue-sfc-parser.d.ts.map +1 -0
  80. package/dist/parsers/vue-sfc-parser.js +183 -0
  81. package/dist/parsers/vue-sfc-parser.js.map +1 -0
  82. package/dist/php-adapter.d.ts +12 -0
  83. package/dist/php-adapter.d.ts.map +1 -0
  84. package/dist/php-adapter.js +42 -0
  85. package/dist/php-adapter.js.map +1 -0
  86. package/dist/remix-adapter.d.ts +13 -0
  87. package/dist/remix-adapter.d.ts.map +1 -0
  88. package/dist/remix-adapter.js +91 -0
  89. package/dist/remix-adapter.js.map +1 -0
  90. package/dist/static-adapter.d.ts +12 -0
  91. package/dist/static-adapter.d.ts.map +1 -0
  92. package/dist/static-adapter.js +44 -0
  93. package/dist/static-adapter.js.map +1 -0
  94. package/dist/sveltekit-adapter.d.ts +13 -0
  95. package/dist/sveltekit-adapter.d.ts.map +1 -0
  96. package/dist/sveltekit-adapter.js +89 -0
  97. package/dist/sveltekit-adapter.js.map +1 -0
  98. package/package.json +23 -3
  99. package/src/astro-adapter.ts +126 -0
  100. package/src/contract.ts +40 -0
  101. package/src/detector.ts +46 -0
  102. package/src/django-adapter.ts +173 -0
  103. package/src/index.ts +23 -0
  104. package/src/nextjs-adapter.ts +116 -0
  105. package/src/nuxt-adapter.ts +118 -0
  106. package/src/parsers/astro-parser.ts +249 -0
  107. package/src/parsers/dtl-parser.ts +166 -0
  108. package/src/parsers/html-parser.ts +214 -0
  109. package/src/parsers/layout-resolver.ts +112 -0
  110. package/src/parsers/php-route-parser.ts +254 -0
  111. package/src/parsers/php-template-parser.ts +303 -0
  112. package/src/parsers/python-url-parser.ts +92 -0
  113. package/src/parsers/remix-parser.ts +161 -0
  114. package/src/parsers/robots-sitemap-parser.ts +128 -0
  115. package/src/parsers/route-normalizer.ts +70 -0
  116. package/src/parsers/svelte-parser.ts +167 -0
  117. package/src/parsers/tsx-ast-parser.ts +404 -0
  118. package/src/parsers/vue-sfc-parser.ts +207 -0
  119. package/src/php-adapter.ts +68 -0
  120. package/src/remix-adapter.ts +123 -0
  121. package/src/static-adapter.ts +62 -0
  122. package/src/sveltekit-adapter.ts +119 -0
  123. package/tsconfig.json +9 -0
  124. package/README.md +0 -3
@@ -0,0 +1,112 @@
1
+ import path from 'node:path';
2
+ import type { ExtractedRouteData } from '@seo-partner/core';
3
+
4
+ export interface ExtractedLayoutData {
5
+ readonly filePath: string;
6
+ readonly htmlLang?: { readonly value: string; readonly line: number } | null;
7
+ readonly title?: { readonly value: string; readonly line: number } | null;
8
+ readonly description?: { readonly value: string; readonly line: number } | null;
9
+ readonly canonical?: { readonly value: string; readonly line: number } | null;
10
+ readonly robots?: { readonly value: string; readonly line: number } | null;
11
+ }
12
+
13
+ export class LayoutResolver {
14
+ private readonly layoutsByPath = new Map<string, ExtractedLayoutData>();
15
+
16
+ public registerLayout(layout: ExtractedLayoutData): void {
17
+ const normalized = layout.filePath.replace(/\\/g, '/');
18
+ this.layoutsByPath.set(normalized, layout);
19
+ }
20
+
21
+ public getLayout(filePath: string): ExtractedLayoutData | undefined {
22
+ const normalized = filePath.replace(/\\/g, '/');
23
+ return this.layoutsByPath.get(normalized);
24
+ }
25
+
26
+ /**
27
+ * Applies layout inheritance to an extracted route if the route lacks metadata
28
+ * (such as htmlLang or fallback title).
29
+ */
30
+ public applyInheritance(
31
+ routeData: ExtractedRouteData,
32
+ parentLayoutPaths: ReadonlyArray<string>,
33
+ ): ExtractedRouteData {
34
+ let htmlLang = routeData.htmlLang;
35
+ let title = routeData.title;
36
+ let description = routeData.description;
37
+ let canonical = routeData.canonical;
38
+ let robots = routeData.robots;
39
+
40
+ // Traverse from nearest layout to root layout
41
+ for (const layoutPath of parentLayoutPaths) {
42
+ const layout = this.getLayout(layoutPath);
43
+ if (!layout) continue;
44
+
45
+ if (!htmlLang && layout.htmlLang) {
46
+ htmlLang = layout.htmlLang;
47
+ }
48
+ if (!title && layout.title) {
49
+ title = layout.title;
50
+ }
51
+ if (!description && layout.description) {
52
+ description = layout.description;
53
+ }
54
+ if (!canonical && layout.canonical) {
55
+ canonical = layout.canonical;
56
+ }
57
+ if (!robots && layout.robots) {
58
+ robots = layout.robots;
59
+ }
60
+ }
61
+
62
+ return {
63
+ ...routeData,
64
+ htmlLang,
65
+ title,
66
+ description,
67
+ canonical,
68
+ robots,
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Finds candidate SvelteKit layout paths (+layout.svelte) starting from the page directory
74
+ * up to the routes root directory.
75
+ */
76
+ public findSvelteKitLayoutPaths(pageFilePath: string): ReadonlyArray<string> {
77
+ const normalized = pageFilePath.replace(/\\/g, '/');
78
+ const parts = normalized.split('/');
79
+ const routesIdx = parts.lastIndexOf('routes');
80
+ if (routesIdx === -1) return [];
81
+
82
+ const candidates: string[] = [];
83
+ const dirParts = parts.slice(0, -1);
84
+
85
+ // From current dir up to routes directory
86
+ for (let i = dirParts.length; i >= routesIdx; i--) {
87
+ const dir = dirParts.slice(0, i).join('/');
88
+ candidates.push(`${dir}/+layout.svelte`);
89
+ }
90
+
91
+ return candidates;
92
+ }
93
+
94
+ /**
95
+ * Resolves Django base template paths (e.g. "base.html" or "layouts/base.html").
96
+ */
97
+ public findDjangoExtendsPath(
98
+ templateContent: string,
99
+ allTemplatePaths: ReadonlyArray<string>,
100
+ ): string | undefined {
101
+ const extendsMatch = templateContent.match(/\{%\s*extends\s+['"]([^'"]+)['"]\s*%\}/i);
102
+ if (!extendsMatch || !extendsMatch[1]) return undefined;
103
+
104
+ const baseName = extendsMatch[1].replace(/\\/g, '/');
105
+ // Match against known templates
106
+ return allTemplatePaths.find((p) => {
107
+ const norm = p.replace(/\\/g, '/');
108
+ return norm.endsWith(baseName) || norm === baseName;
109
+ });
110
+ }
111
+ }
112
+
@@ -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
+