@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,123 @@
1
+ export interface MetadataFields {
2
+ title?: string;
3
+ description?: string;
4
+ canonical?: string;
5
+ openGraph?: {
6
+ title?: string;
7
+ description?: string;
8
+ url?: string;
9
+ siteName?: string;
10
+ };
11
+ }
12
+
13
+ export interface AstMergeResult {
14
+ mergedCode: string;
15
+ modified: boolean;
16
+ hasComplexDynamicLogic: boolean;
17
+ isClientComponent: boolean;
18
+ changes: Array<{
19
+ field: string;
20
+ before?: string | null;
21
+ after: string;
22
+ }>;
23
+ }
24
+
25
+ export function mergePageMetadataInPlace(
26
+ sourceCode: string,
27
+ newMeta: MetadataFields,
28
+ ): AstMergeResult {
29
+ const isClientComponent = /^\s*['"]use client['"]/m.test(sourceCode);
30
+ const hasGenerateMetadata = /\bexport\s+(async\s+)?function\s+generateMetadata\b/.test(sourceCode);
31
+
32
+ if (hasGenerateMetadata) {
33
+ return {
34
+ mergedCode: sourceCode,
35
+ modified: false,
36
+ hasComplexDynamicLogic: true,
37
+ isClientComponent,
38
+ changes: [],
39
+ };
40
+ }
41
+
42
+ const changes: Array<{ field: string; before?: string | null; after: string }> = [];
43
+
44
+ // Match existing `export const metadata = { ... }` or `export const metadata: Metadata = { ... }`
45
+ const metadataRegex = /(export\s+const\s+metadata(?:\s*:\s*\w+)?\s*=\s*\{)([\s\S]*?)(\};?)/m;
46
+ const match = metadataRegex.exec(sourceCode);
47
+
48
+ if (match) {
49
+ const prefix = match[1] ?? '';
50
+ let body = match[2] ?? '';
51
+ const suffix = match[3] ?? '';
52
+
53
+ // Check title
54
+ if (newMeta.title && !/\btitle\s*:/m.test(body)) {
55
+ const val = newMeta.title;
56
+ body = `\n title: '${val.replace(/'/g, "\\'")}',` + body;
57
+ changes.push({ field: 'title', before: null, after: val });
58
+ }
59
+
60
+ // Check description
61
+ if (newMeta.description && !/\bdescription\s*:/m.test(body)) {
62
+ const val = newMeta.description;
63
+ body = `\n description: '${val.replace(/'/g, "\\'")}',` + body;
64
+ changes.push({ field: 'description', before: null, after: val });
65
+ }
66
+
67
+ // Check alternates / canonical
68
+ if (newMeta.canonical && !/\balternates\s*:/m.test(body)) {
69
+ const val = newMeta.canonical;
70
+ body = body + `\n alternates: {\n canonical: '${val}',\n },`;
71
+ changes.push({ field: 'alternates.canonical', before: null, after: val });
72
+ }
73
+
74
+ // Check openGraph
75
+ if (newMeta.openGraph && !/\bopenGraph\s*:/m.test(body)) {
76
+ const ogTitle = newMeta.openGraph.title ?? newMeta.title ?? '';
77
+ const ogDesc = newMeta.openGraph.description ?? newMeta.description ?? '';
78
+ body = body + `\n openGraph: {\n title: '${ogTitle.replace(/'/g, "\\'")}',\n description: '${ogDesc.replace(/'/g, "\\'")}',\n },`;
79
+ changes.push({ field: 'openGraph', before: null, after: ogTitle });
80
+ }
81
+
82
+ const mergedCode = sourceCode.replace(metadataRegex, `${prefix}${body}\n${suffix}`);
83
+ return {
84
+ mergedCode,
85
+ modified: changes.length > 0,
86
+ hasComplexDynamicLogic: false,
87
+ isClientComponent,
88
+ changes,
89
+ };
90
+ }
91
+
92
+ // No existing metadata block: create one
93
+ const entries: string[] = [];
94
+ if (newMeta.title) {
95
+ entries.push(` title: '${newMeta.title.replace(/'/g, "\\'")}',`);
96
+ changes.push({ field: 'title', before: null, after: newMeta.title });
97
+ }
98
+ if (newMeta.description) {
99
+ entries.push(` description: '${newMeta.description.replace(/'/g, "\\'")}',`);
100
+ changes.push({ field: 'description', before: null, after: newMeta.description });
101
+ }
102
+ if (newMeta.canonical) {
103
+ entries.push(` alternates: {\n canonical: '${newMeta.canonical}',\n },`);
104
+ changes.push({ field: 'alternates.canonical', before: null, after: newMeta.canonical });
105
+ }
106
+ if (newMeta.openGraph) {
107
+ const ogTitle = newMeta.openGraph.title ?? newMeta.title ?? '';
108
+ const ogDesc = newMeta.openGraph.description ?? newMeta.description ?? '';
109
+ entries.push(` openGraph: {\n title: '${ogTitle.replace(/'/g, "\\'")}',\n description: '${ogDesc.replace(/'/g, "\\'")}',\n },`);
110
+ changes.push({ field: 'openGraph', before: null, after: ogTitle });
111
+ }
112
+
113
+ const newExport = `\nexport const metadata = {\n${entries.join('\n')}\n};\n`;
114
+ const mergedCode = sourceCode + newExport;
115
+
116
+ return {
117
+ mergedCode,
118
+ modified: true,
119
+ hasComplexDynamicLogic: false,
120
+ isClientComponent,
121
+ changes,
122
+ };
123
+ }
@@ -0,0 +1,155 @@
1
+ import path from 'node:path';
2
+ import type { DiscoveredRoute, ExtractedRouteData } from '@seo-partner/core';
3
+ import type { AdapterCapabilities, AdapterVersion, FrameworkAdapter } from './contract.js';
4
+ import { parseAstroOrMarkdown } from './parsers/astro-parser.js';
5
+ import { normalizeRoutePattern } from './parsers/route-normalizer.js';
6
+ import { LayoutResolver } from './parsers/layout-resolver.js';
7
+
8
+ export class AstroAdapter implements FrameworkAdapter {
9
+ public readonly id = 'astro';
10
+ public readonly name = 'Astro';
11
+ public readonly version: AdapterVersion = '1.0';
12
+ public readonly capabilities: AdapterCapabilities = {
13
+ routes: true,
14
+ metadata: true,
15
+ headings: true,
16
+ internalLinks: true,
17
+ jsonLd: true,
18
+ images: true,
19
+ robotsAndSitemap: true,
20
+ };
21
+
22
+ private readonly layoutResolver = new LayoutResolver();
23
+
24
+ public async detect(
25
+ projectRoot: string,
26
+ filePaths: ReadonlyArray<string>,
27
+ contentsByPath: ReadonlyMap<string, string>,
28
+ ): Promise<boolean> {
29
+ for (const filePath of filePaths) {
30
+ const normalized = filePath.replace(/\\/g, '/');
31
+ if (
32
+ normalized === 'astro.config.mjs' ||
33
+ normalized === 'astro.config.ts' ||
34
+ normalized === 'astro.config.js' ||
35
+ normalized === 'astro.config.cjs'
36
+ ) {
37
+ return true;
38
+ }
39
+ if (normalized === 'package.json') {
40
+ const content = contentsByPath.get(filePath) ?? '';
41
+ if (content.includes('"astro"') || content.includes('"@astrojs/')) {
42
+ return true;
43
+ }
44
+ }
45
+ }
46
+ return false;
47
+ }
48
+
49
+ public async discoverRoutes(
50
+ projectRoot: string,
51
+ filePaths: ReadonlyArray<string>,
52
+ contentsByPath: ReadonlyMap<string, string>,
53
+ sitemapPaths: ReadonlySet<string>,
54
+ ): Promise<ReadonlyArray<DiscoveredRoute>> {
55
+ const routes: DiscoveredRoute[] = [];
56
+
57
+ // Pre-cache layouts from src/layouts/**
58
+ for (const filePath of filePaths) {
59
+ const normalized = filePath.replace(/\\/g, '/');
60
+ if (normalized.startsWith('src/layouts/') && normalized.endsWith('.astro')) {
61
+ const content = contentsByPath.get(filePath) ?? '';
62
+ const parsed = parseAstroOrMarkdown(
63
+ {
64
+ pattern: '',
65
+ filePath,
66
+ routerKind: 'astro',
67
+ renderingMode: 'static',
68
+ isPrivate: false,
69
+ inSitemap: false,
70
+ },
71
+ content,
72
+ );
73
+ this.layoutResolver.registerLayout({
74
+ filePath,
75
+ htmlLang: parsed.routeData.htmlLang,
76
+ title: parsed.routeData.title,
77
+ description: parsed.routeData.description,
78
+ canonical: parsed.routeData.canonical,
79
+ robots: parsed.routeData.robots,
80
+ });
81
+ }
82
+ }
83
+
84
+ for (const filePath of filePaths) {
85
+ const normalized = filePath.replace(/\\/g, '/');
86
+ if (!normalized.startsWith('src/pages/')) continue;
87
+ if (!/\.(astro|md|mdx|html)$/i.test(normalized)) continue;
88
+
89
+ const relativeInsidePages = normalized.slice('src/pages/'.length);
90
+ const pattern = normalizeRoutePattern(relativeInsidePages, 'astro');
91
+ const inSitemap = sitemapPaths.has(pattern);
92
+
93
+ routes.push({
94
+ pattern,
95
+ filePath,
96
+ routerKind: 'astro',
97
+ renderingMode: 'static',
98
+ isPrivate: false,
99
+ inSitemap,
100
+ });
101
+ }
102
+
103
+ return routes;
104
+ }
105
+
106
+ public async extractRoute(
107
+ projectRoot: string,
108
+ route: DiscoveredRoute,
109
+ fileContent: string,
110
+ ): Promise<ExtractedRouteData> {
111
+ const parsed = parseAstroOrMarkdown(route, fileContent);
112
+ let extracted = parsed.routeData;
113
+
114
+ // Apply layout inheritance: check layout candidates
115
+ const layoutCandidates = [
116
+ 'src/layouts/BaseLayout.astro',
117
+ 'src/layouts/Layout.astro',
118
+ 'src/layouts/MainLayout.astro',
119
+ 'src/layouts/DefaultLayout.astro',
120
+ ];
121
+ extracted = this.layoutResolver.applyInheritance(extracted, layoutCandidates);
122
+
123
+ return extracted;
124
+ }
125
+ }
126
+
127
+
128
+ export interface AstroMetaFields {
129
+ title?: string;
130
+ description?: string;
131
+ canonical?: string;
132
+ }
133
+
134
+ export function patchAstroTemplate(
135
+ sourceCode: string,
136
+ meta: AstroMetaFields,
137
+ ): string {
138
+ let result = sourceCode;
139
+
140
+ const entries: string[] = [];
141
+ if (meta.title) entries.push(`title: "${meta.title.replace(/"/g, '\\"')}"`);
142
+ if (meta.description) entries.push(`description: "${meta.description.replace(/"/g, '\\"')}"`);
143
+ if (meta.canonical) entries.push(`canonical: "${meta.canonical}"`);
144
+
145
+ const seoVarBlock = `const seoMeta = {\n ${entries.join(',\n ')}\n};\n`;
146
+
147
+ const frontmatterMatch = /^---\s*\n([\s\S]*?)\n---/m.exec(result);
148
+ if (frontmatterMatch) {
149
+ result = result.replace(/^---\s*\n/m, `---\n${seoVarBlock}`);
150
+ } else {
151
+ result = `---\n${seoVarBlock}---\n\n` + result;
152
+ }
153
+
154
+ return result;
155
+ }
@@ -0,0 +1,153 @@
1
+ import type { FrameworkType } from '@seo-partner/core';
2
+
3
+ export interface BlogScaffoldFile {
4
+ relativePath: string;
5
+ code: string;
6
+ description: string;
7
+ }
8
+
9
+ export function generateBlogScaffold(
10
+ framework: FrameworkType,
11
+ pillarTitle = 'Actualités et Guides SEO',
12
+ ): BlogScaffoldFile[] {
13
+ switch (framework) {
14
+ case 'next_app':
15
+ return [
16
+ {
17
+ relativePath: 'app/blog/page.tsx',
18
+ code: `import type { Metadata } from 'next';
19
+ import Link from 'next/link';
20
+
21
+ export const metadata: Metadata = {
22
+ title: '${pillarTitle} | Blog',
23
+ description: 'Découvrez nos guides d\\\'experts et analyses approfondies.',
24
+ alternates: {
25
+ canonical: '/blog',
26
+ },
27
+ };
28
+
29
+ export default function BlogIndexPage() {
30
+ return (
31
+ <main className="max-w-4xl mx-auto py-12 px-4">
32
+ <h1 className="text-3xl font-bold mb-8">${pillarTitle}</h1>
33
+ <p className="text-gray-600 mb-6">Explorez nos articles et guides complets pour approfondir vos compétences.</p>
34
+ </main>
35
+ );
36
+ }
37
+ `,
38
+ description: 'Page d\'index du blog Next.js App Router',
39
+ },
40
+ {
41
+ relativePath: 'app/blog/[slug]/page.tsx',
42
+ code: `import type { Metadata } from 'next';
43
+ import { notFound } from 'next/navigation';
44
+
45
+ interface Props {
46
+ params: { slug: string };
47
+ }
48
+
49
+ export async function generateMetadata({ params }: Props): Promise<Metadata> {
50
+ return {
51
+ title: \`Article: \${params.slug} | Blog\`,
52
+ description: 'Guide complet et recommandations pratiques.',
53
+ };
54
+ }
55
+
56
+ export default function BlogPostPage({ params }: Props) {
57
+ return (
58
+ <article className="max-w-3xl mx-auto py-12 px-4 prose">
59
+ <h1>Détail de l\\\'article : {params.slug}</h1>
60
+ </article>
61
+ );
62
+ }
63
+ `,
64
+ description: 'Route dynamique d\'article Next.js App Router',
65
+ },
66
+ ];
67
+
68
+ case 'nuxt':
69
+ return [
70
+ {
71
+ relativePath: 'pages/blog/index.vue',
72
+ code: `<script setup lang="ts">
73
+ useSeoMeta({
74
+ title: '${pillarTitle} | Blog',
75
+ description: 'Guides d\\\'experts et ressources pratiques.',
76
+ });
77
+ </script>
78
+
79
+ <template>
80
+ <main class="blog-index">
81
+ <h1>${pillarTitle}</h1>
82
+ </main>
83
+ </template>
84
+ `,
85
+ description: 'Page d\'index du blog Nuxt 3',
86
+ },
87
+ ];
88
+
89
+ case 'astro':
90
+ return [
91
+ {
92
+ relativePath: 'src/pages/blog/index.astro',
93
+ code: `---
94
+ const pageTitle = "${pillarTitle}";
95
+ ---
96
+ <html>
97
+ <head>
98
+ <title>{pageTitle}</title>
99
+ <meta name="description" content="Guides et analyses éditoriales d\\\'experts." />
100
+ </head>
101
+ <body>
102
+ <main>
103
+ <h1>{pageTitle}</h1>
104
+ </main>
105
+ </body>
106
+ </html>
107
+ `,
108
+ description: 'Page d\'index du blog Astro',
109
+ },
110
+ ];
111
+
112
+ case 'laravel':
113
+ return [
114
+ {
115
+ relativePath: 'resources/views/blog/index.blade.php',
116
+ code: `@extends('layouts.app')
117
+
118
+ @section('title', '${pillarTitle}')
119
+
120
+ @push('meta')
121
+ <meta name="description" content="Retrouvez nos guides et articles spécialisés.">
122
+ @endpush
123
+
124
+ @section('content')
125
+ <div class="container py-5">
126
+ <h1>${pillarTitle}</h1>
127
+ </div>
128
+ @endsection
129
+ `,
130
+ description: 'Vue d\'index du blog Laravel Blade',
131
+ },
132
+ ];
133
+
134
+ default:
135
+ return [
136
+ {
137
+ relativePath: 'blog/index.php',
138
+ code: `<?php
139
+ $pageTitle = '${pillarTitle}';
140
+ $metaDescription = 'Guides et ressources d\\\'experts.';
141
+ require_once __DIR__ . '/../includes/header.php';
142
+ ?>
143
+ <main class="container">
144
+ <h1><?php echo htmlspecialchars($pageTitle); ?></h1>
145
+ </main>
146
+ <?php require_once __DIR__ . '/../includes/footer.php'; ?>
147
+ `,
148
+ description: 'Script d\'index du blog PHP',
149
+ },
150
+ ];
151
+ }
152
+ }
153
+
@@ -0,0 +1,40 @@
1
+ import type { DiscoveredRoute, ExtractedRouteData } from '@seo-partner/core';
2
+
3
+ export type AdapterVersion = '1.0';
4
+
5
+ export interface AdapterCapabilities {
6
+ readonly routes: boolean;
7
+ readonly metadata: boolean;
8
+ readonly headings: boolean;
9
+ readonly internalLinks: boolean;
10
+ readonly jsonLd: boolean;
11
+ readonly images: boolean;
12
+ readonly robotsAndSitemap: boolean;
13
+ }
14
+
15
+ export interface FrameworkAdapter {
16
+ readonly id: string;
17
+ readonly name: string;
18
+ readonly version: AdapterVersion;
19
+ readonly capabilities: AdapterCapabilities;
20
+
21
+ detect(
22
+ projectRoot: string,
23
+ filePaths: ReadonlyArray<string>,
24
+ contentsByPath: ReadonlyMap<string, string>,
25
+ ): Promise<boolean>;
26
+
27
+ discoverRoutes(
28
+ projectRoot: string,
29
+ filePaths: ReadonlyArray<string>,
30
+ contentsByPath: ReadonlyMap<string, string>,
31
+ sitemapPaths: ReadonlySet<string>,
32
+ ): Promise<ReadonlyArray<DiscoveredRoute>>;
33
+
34
+ extractRoute(
35
+ projectRoot: string,
36
+ route: DiscoveredRoute,
37
+ fileContent: string,
38
+ ): Promise<ExtractedRouteData>;
39
+ }
40
+
@@ -0,0 +1,46 @@
1
+ import type { FrameworkAdapter } from './contract.js';
2
+ import { NextJsAdapter } from './nextjs-adapter.js';
3
+ import { AstroAdapter } from './astro-adapter.js';
4
+ import { NuxtAdapter } from './nuxt-adapter.js';
5
+ import { SvelteKitAdapter } from './sveltekit-adapter.js';
6
+ import { RemixAdapter } from './remix-adapter.js';
7
+ import { PhpAdapter } from './php-adapter.js';
8
+ import { DjangoAdapter } from './django-adapter.js';
9
+ import { StaticAdapter } from './static-adapter.js';
10
+
11
+ /**
12
+ * Detection order contract (strictly prioritized to avoid ambiguities):
13
+ * 1. Next.js
14
+ * 2. Astro
15
+ * 3. Nuxt 3
16
+ * 4. SvelteKit
17
+ * 5. Remix / React Router v7
18
+ * 6. PHP (Laravel / Symfony / Native)
19
+ * 7. Django (Python)
20
+ * 8. Static HTML
21
+ */
22
+ export const BUILTIN_ADAPTERS: ReadonlyArray<FrameworkAdapter> = [
23
+ new NextJsAdapter(),
24
+ new AstroAdapter(),
25
+ new NuxtAdapter(),
26
+ new SvelteKitAdapter(),
27
+ new RemixAdapter(),
28
+ new PhpAdapter(),
29
+ new DjangoAdapter(),
30
+ new StaticAdapter(),
31
+ ];
32
+
33
+ export async function detectActiveAdapters(
34
+ projectRoot: string,
35
+ filePaths: ReadonlyArray<string>,
36
+ contentsByPath: ReadonlyMap<string, string>,
37
+ ): Promise<ReadonlyArray<FrameworkAdapter>> {
38
+ const active: FrameworkAdapter[] = [];
39
+ for (const adapter of BUILTIN_ADAPTERS) {
40
+ const matched = await adapter.detect(projectRoot, filePaths, contentsByPath);
41
+ if (matched) {
42
+ active.push(adapter);
43
+ }
44
+ }
45
+ return active;
46
+ }
@@ -0,0 +1,173 @@
1
+ import path from 'node:path';
2
+ import type { DiscoveredRoute, ExtractedRouteData } from '@seo-partner/core';
3
+ import type { AdapterCapabilities, AdapterVersion, FrameworkAdapter } from './contract.js';
4
+ import { parseDjangoUrls, parseDjangoViews } from './parsers/python-url-parser.js';
5
+ import { parseDjangoTemplate } from './parsers/dtl-parser.js';
6
+ import { LayoutResolver } from './parsers/layout-resolver.js';
7
+
8
+ export class DjangoAdapter implements FrameworkAdapter {
9
+ public readonly id = 'django';
10
+ public readonly name = 'Django';
11
+ public readonly version: AdapterVersion = '1.0';
12
+ public readonly capabilities: AdapterCapabilities = {
13
+ routes: true,
14
+ metadata: true,
15
+ headings: true,
16
+ internalLinks: true,
17
+ jsonLd: true,
18
+ images: true,
19
+ robotsAndSitemap: true,
20
+ };
21
+
22
+ private readonly layoutResolver = new LayoutResolver();
23
+
24
+ public async detect(
25
+ projectRoot: string,
26
+ filePaths: ReadonlyArray<string>,
27
+ contentsByPath: ReadonlyMap<string, string>,
28
+ ): Promise<boolean> {
29
+ for (const filePath of filePaths) {
30
+ const normalized = filePath.replace(/\\/g, '/');
31
+ if (
32
+ normalized === 'manage.py' ||
33
+ normalized.endsWith('/manage.py') ||
34
+ normalized === 'wsgi.py' ||
35
+ normalized.endsWith('/wsgi.py') ||
36
+ normalized === 'asgi.py' ||
37
+ normalized.endsWith('/asgi.py') ||
38
+ normalized.endsWith('settings.py')
39
+ ) {
40
+ return true;
41
+ }
42
+ }
43
+ return false;
44
+ }
45
+
46
+ public async discoverRoutes(
47
+ projectRoot: string,
48
+ filePaths: ReadonlyArray<string>,
49
+ contentsByPath: ReadonlyMap<string, string>,
50
+ sitemapPaths: ReadonlySet<string>,
51
+ ): Promise<ReadonlyArray<DiscoveredRoute>> {
52
+ const routes: DiscoveredRoute[] = [];
53
+
54
+ // 1. Pre-cache all base templates (e.g. templates/base.html, templates/*/base.html)
55
+ for (const filePath of filePaths) {
56
+ const normalized = filePath.replace(/\\/g, '/');
57
+ if (normalized.endsWith('.html')) {
58
+ const content = contentsByPath.get(filePath) ?? '';
59
+ if (
60
+ normalized.endsWith('base.html') ||
61
+ content.includes('<!DOCTYPE html>') ||
62
+ content.includes('<html')
63
+ ) {
64
+ const parsed = parseDjangoTemplate(
65
+ {
66
+ pattern: '',
67
+ filePath,
68
+ routerKind: 'django',
69
+ renderingMode: 'dynamic',
70
+ isPrivate: false,
71
+ inSitemap: false,
72
+ },
73
+ content,
74
+ );
75
+ this.layoutResolver.registerLayout({
76
+ filePath,
77
+ htmlLang: parsed.routeData.htmlLang,
78
+ title: parsed.routeData.title,
79
+ description: parsed.routeData.description,
80
+ canonical: parsed.routeData.canonical,
81
+ robots: parsed.routeData.robots,
82
+ });
83
+ }
84
+ }
85
+ }
86
+
87
+ // 2. Parse all views.py to build viewName -> templateName mapping
88
+ const viewToTemplate = new Map<string, string>();
89
+ for (const filePath of filePaths) {
90
+ const normalized = filePath.replace(/\\/g, '/');
91
+ if (normalized.endsWith('views.py')) {
92
+ const content = contentsByPath.get(filePath) ?? '';
93
+ const mappings = parseDjangoViews(content);
94
+ for (const m of mappings) {
95
+ viewToTemplate.set(m.viewName, m.templateName);
96
+ }
97
+ }
98
+ }
99
+
100
+ // 3. Parse all urls.py to discover routes
101
+ for (const filePath of filePaths) {
102
+ const normalized = filePath.replace(/\\/g, '/');
103
+ if (!normalized.endsWith('urls.py')) continue;
104
+
105
+ const content = contentsByPath.get(filePath) ?? '';
106
+ const patterns = parseDjangoUrls(content);
107
+
108
+ for (const p of patterns) {
109
+ const targetTemplateName = viewToTemplate.get(p.viewName);
110
+ let resolvedFilePath = filePath;
111
+
112
+ if (targetTemplateName) {
113
+ // Find matching template in filePaths
114
+ const found = filePaths.find((fp) => {
115
+ const norm = fp.replace(/\\/g, '/');
116
+ return norm.endsWith(`/${targetTemplateName}`) || norm === targetTemplateName;
117
+ });
118
+ if (found) {
119
+ resolvedFilePath = found;
120
+ }
121
+ } else {
122
+ // Conventional fallback: check for templates/<viewName>.html
123
+ const fallback = filePaths.find((fp) => {
124
+ const norm = fp.replace(/\\/g, '/');
125
+ return (
126
+ norm.endsWith(`/${p.viewName}.html`) ||
127
+ norm.endsWith(`templates/${p.viewName}.html`)
128
+ );
129
+ });
130
+ if (fallback) {
131
+ resolvedFilePath = fallback;
132
+ }
133
+ }
134
+
135
+ const inSitemap = sitemapPaths.has(p.pattern) || sitemapPaths.has(`${p.pattern}/`);
136
+
137
+ routes.push({
138
+ pattern: p.pattern,
139
+ filePath: resolvedFilePath,
140
+ routerKind: 'django',
141
+ renderingMode: 'dynamic',
142
+ isPrivate: false,
143
+ inSitemap,
144
+ });
145
+ }
146
+ }
147
+
148
+ return routes;
149
+ }
150
+
151
+ public async extractRoute(
152
+ projectRoot: string,
153
+ route: DiscoveredRoute,
154
+ fileContent: string,
155
+ ): Promise<ExtractedRouteData> {
156
+ const parsed = parseDjangoTemplate(route, fileContent);
157
+ let extracted = parsed.routeData;
158
+
159
+ // Resolve base template inheritance
160
+ const candidates: string[] = [];
161
+ if (parsed.extendsTemplate) {
162
+ candidates.push(parsed.extendsTemplate);
163
+ candidates.push(`templates/${parsed.extendsTemplate}`);
164
+ }
165
+ candidates.push('templates/base.html');
166
+ candidates.push('base.html');
167
+
168
+ extracted = this.layoutResolver.applyInheritance(extracted, candidates);
169
+
170
+ return extracted;
171
+ }
172
+ }
173
+