@seo-partner/adapters 1.0.0 → 1.0.4

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 (61) hide show
  1. package/dist/ast-merger.d.ts +24 -0
  2. package/dist/ast-merger.d.ts.map +1 -0
  3. package/dist/ast-merger.js +85 -0
  4. package/dist/ast-merger.js.map +1 -0
  5. package/dist/astro-adapter.d.ts +6 -0
  6. package/dist/astro-adapter.d.ts.map +1 -1
  7. package/dist/astro-adapter.js +19 -0
  8. package/dist/astro-adapter.js.map +1 -1
  9. package/dist/blog-scaffold-generator.d.ts +8 -0
  10. package/dist/blog-scaffold-generator.d.ts.map +1 -0
  11. package/dist/blog-scaffold-generator.js +138 -0
  12. package/dist/blog-scaffold-generator.js.map +1 -0
  13. package/dist/index.d.ts +8 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +8 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/laravel-adapter.d.ts +7 -0
  18. package/dist/laravel-adapter.d.ts.map +1 -0
  19. package/dist/laravel-adapter.js +35 -0
  20. package/dist/laravel-adapter.js.map +1 -0
  21. package/dist/layout-title-resolver.d.ts +8 -0
  22. package/dist/layout-title-resolver.d.ts.map +1 -0
  23. package/dist/layout-title-resolver.js +28 -0
  24. package/dist/layout-title-resolver.js.map +1 -0
  25. package/dist/nuxt-adapter.d.ts +6 -0
  26. package/dist/nuxt-adapter.d.ts.map +1 -1
  27. package/dist/nuxt-adapter.js +20 -0
  28. package/dist/nuxt-adapter.js.map +1 -1
  29. package/dist/php-native-adapter.d.ts +7 -0
  30. package/dist/php-native-adapter.d.ts.map +1 -0
  31. package/dist/php-native-adapter.js +31 -0
  32. package/dist/php-native-adapter.js.map +1 -0
  33. package/dist/sitemap-builder.d.ts +10 -0
  34. package/dist/sitemap-builder.d.ts.map +1 -0
  35. package/dist/sitemap-builder.js +37 -0
  36. package/dist/sitemap-builder.js.map +1 -0
  37. package/dist/skeleton-detector.d.ts +14 -0
  38. package/dist/skeleton-detector.d.ts.map +1 -0
  39. package/dist/skeleton-detector.js +33 -0
  40. package/dist/skeleton-detector.js.map +1 -0
  41. package/dist/sveltekit-adapter.d.ts +6 -0
  42. package/dist/sveltekit-adapter.d.ts.map +1 -1
  43. package/dist/sveltekit-adapter.js +26 -0
  44. package/dist/sveltekit-adapter.js.map +1 -1
  45. package/dist/symfony-adapter.d.ts +7 -0
  46. package/dist/symfony-adapter.d.ts.map +1 -0
  47. package/dist/symfony-adapter.js +23 -0
  48. package/dist/symfony-adapter.js.map +1 -0
  49. package/package.json +2 -2
  50. package/src/ast-merger.ts +123 -0
  51. package/src/astro-adapter.ts +155 -126
  52. package/src/blog-scaffold-generator.ts +153 -0
  53. package/src/index.ts +8 -0
  54. package/src/laravel-adapter.ts +45 -0
  55. package/src/layout-title-resolver.ts +37 -0
  56. package/src/nuxt-adapter.ts +148 -118
  57. package/src/php-native-adapter.ts +46 -0
  58. package/src/sitemap-builder.ts +57 -0
  59. package/src/skeleton-detector.ts +56 -0
  60. package/src/sveltekit-adapter.ts +160 -119
  61. package/src/symfony-adapter.ts +33 -0
@@ -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
+
package/src/index.ts CHANGED
@@ -7,6 +7,9 @@ export * from './sveltekit-adapter.js';
7
7
  export * from './remix-adapter.js';
8
8
  export * from './django-adapter.js';
9
9
  export * from './php-adapter.js';
10
+ export * from './laravel-adapter.js';
11
+ export * from './symfony-adapter.js';
12
+ export * from './php-native-adapter.js';
10
13
  export * from './static-adapter.js';
11
14
  export * from './parsers/tsx-ast-parser.js';
12
15
  export * from './parsers/html-parser.js';
@@ -21,3 +24,8 @@ export * from './parsers/php-template-parser.js';
21
24
  export * from './parsers/robots-sitemap-parser.js';
22
25
  export * from './parsers/route-normalizer.js';
23
26
  export * from './parsers/layout-resolver.js';
27
+ export * from './sitemap-builder.js';
28
+ export * from './skeleton-detector.js';
29
+ export * from './ast-merger.js';
30
+ export * from './layout-title-resolver.js';
31
+ export * from './blog-scaffold-generator.js';
@@ -0,0 +1,45 @@
1
+ export interface LaravelMetaFields {
2
+ title?: string;
3
+ description?: string;
4
+ canonical?: string;
5
+ }
6
+
7
+ export function patchLaravelBladeTemplate(
8
+ sourceCode: string,
9
+ meta: LaravelMetaFields,
10
+ ): string {
11
+ let result = sourceCode;
12
+
13
+ // Title section
14
+ if (meta.title && !/@section\(\s*['"]title['"]/i.test(result)) {
15
+ const sectionTitle = `@section('title', '${meta.title.replace(/'/g, "\\'")}')\n`;
16
+ if (/@extends\([^)]+\)/i.test(result)) {
17
+ result = result.replace(/(@extends\([^)]+\)\s*\n)/i, `$1${sectionTitle}`);
18
+ } else {
19
+ result = sectionTitle + result;
20
+ }
21
+ }
22
+
23
+ // Meta push
24
+ if ((meta.description || meta.canonical) && !/@push\(\s*['"]meta['"]/i.test(result)) {
25
+ const metaTags: string[] = ["@push('meta')"];
26
+ if (meta.description) {
27
+ metaTags.push(` <meta name="description" content="${meta.description.replace(/"/g, '&quot;')}">`);
28
+ }
29
+ if (meta.canonical) {
30
+ metaTags.push(` <link rel="canonical" href="${meta.canonical}">`);
31
+ }
32
+ metaTags.push('@endpush\n');
33
+
34
+ if (/@section\(\s*['"]title['"][^)]*\)/i.test(result)) {
35
+ result = result.replace(/(@section\(\s*['"]title['"][^)]*\)\s*\n)/i, `$1${metaTags.join('\n')}`);
36
+ } else if (/@extends\([^)]+\)/i.test(result)) {
37
+ result = result.replace(/(@extends\([^)]+\)\s*\n)/i, `$1${metaTags.join('\n')}`);
38
+ } else {
39
+ result = metaTags.join('\n') + result;
40
+ }
41
+ }
42
+
43
+ return result;
44
+ }
45
+
@@ -0,0 +1,37 @@
1
+ export interface LayoutTitleTemplate {
2
+ template?: string;
3
+ defaultTitle?: string;
4
+ brandName?: string;
5
+ }
6
+
7
+ export function extractLayoutTitleTemplate(layoutCode: string): LayoutTitleTemplate | null {
8
+ const match = /title\s*:\s*\{([^}]+)\}/m.exec(layoutCode);
9
+ if (!match || !match[1]) return null;
10
+
11
+ const body = match[1];
12
+ const templateMatch = /template\s*:\s*['"`]([^'"`]+)['"`]/.exec(body);
13
+ const defaultMatch = /default\s*:\s*['"`]([^'"`]+)['"`]/.exec(body);
14
+
15
+ const template = templateMatch?.[1];
16
+ const defaultTitle = defaultMatch?.[1];
17
+
18
+ let brandName: string | undefined;
19
+ if (template && template.includes('%s')) {
20
+ brandName = template.replace('%s', '').replace(/^[| -]+|[| -]+$/g, '').trim();
21
+ }
22
+
23
+ return {
24
+ template,
25
+ defaultTitle,
26
+ brandName,
27
+ };
28
+ }
29
+
30
+ export function sanitizePageTitleForLayout(pageTitle: string, layoutTemplate: LayoutTitleTemplate | null): string {
31
+ if (!layoutTemplate?.brandName) {
32
+ return pageTitle;
33
+ }
34
+ const brand = layoutTemplate.brandName;
35
+ const regex = new RegExp(`(\\s*[|\\-]\\s*${brand.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&')})+$`, 'i');
36
+ return pageTitle.replace(regex, '').trim();
37
+ }
@@ -1,118 +1,148 @@
1
- import type { DiscoveredRoute, ExtractedRouteData } from '@seo-partner/core';
2
- import type { AdapterCapabilities, AdapterVersion, FrameworkAdapter } from './contract.js';
3
- import { parseVueSfc } from './parsers/vue-sfc-parser.js';
4
- import { normalizeRoutePattern } from './parsers/route-normalizer.js';
5
- import { LayoutResolver } from './parsers/layout-resolver.js';
6
-
7
- export class NuxtAdapter implements FrameworkAdapter {
8
- public readonly id = 'nuxt';
9
- public readonly name = 'Nuxt 3';
10
- public readonly version: AdapterVersion = '1.0';
11
- public readonly capabilities: AdapterCapabilities = {
12
- routes: true,
13
- metadata: true,
14
- headings: true,
15
- internalLinks: true,
16
- jsonLd: true,
17
- images: true,
18
- robotsAndSitemap: true,
19
- };
20
-
21
- private readonly layoutResolver = new LayoutResolver();
22
-
23
- public async detect(
24
- projectRoot: string,
25
- filePaths: ReadonlyArray<string>,
26
- contentsByPath: ReadonlyMap<string, string>,
27
- ): Promise<boolean> {
28
- for (const filePath of filePaths) {
29
- const normalized = filePath.replace(/\\/g, '/');
30
- if (
31
- normalized === 'nuxt.config.ts' ||
32
- normalized === 'nuxt.config.js' ||
33
- normalized === 'nuxt.config.mjs'
34
- ) {
35
- return true;
36
- }
37
- if (normalized === 'package.json') {
38
- const content = contentsByPath.get(filePath) ?? '';
39
- if (content.includes('"nuxt"') || content.includes('"@nuxt/')) {
40
- return true;
41
- }
42
- }
43
- }
44
- return false;
45
- }
46
-
47
- public async discoverRoutes(
48
- projectRoot: string,
49
- filePaths: ReadonlyArray<string>,
50
- contentsByPath: ReadonlyMap<string, string>,
51
- sitemapPaths: ReadonlySet<string>,
52
- ): Promise<ReadonlyArray<DiscoveredRoute>> {
53
- const routes: DiscoveredRoute[] = [];
54
-
55
- // Pre-cache layouts from layouts/**
56
- for (const filePath of filePaths) {
57
- const normalized = filePath.replace(/\\/g, '/');
58
- if (normalized.startsWith('layouts/') && normalized.endsWith('.vue')) {
59
- const content = contentsByPath.get(filePath) ?? '';
60
- const parsed = parseVueSfc(
61
- {
62
- pattern: '',
63
- filePath,
64
- routerKind: 'nuxt',
65
- renderingMode: 'ssr-ssg',
66
- isPrivate: false,
67
- inSitemap: false,
68
- },
69
- content,
70
- );
71
- this.layoutResolver.registerLayout({
72
- filePath,
73
- htmlLang: parsed.htmlLang,
74
- title: parsed.title,
75
- description: parsed.description,
76
- canonical: parsed.canonical,
77
- robots: parsed.robots,
78
- });
79
- }
80
- }
81
-
82
- for (const filePath of filePaths) {
83
- const normalized = filePath.replace(/\\/g, '/');
84
- if (!normalized.startsWith('pages/')) continue;
85
- if (!normalized.endsWith('.vue')) continue;
86
-
87
- const relativeInsidePages = normalized.slice('pages/'.length);
88
- const pattern = normalizeRoutePattern(relativeInsidePages, 'nuxt');
89
- const inSitemap = sitemapPaths.has(pattern);
90
-
91
- routes.push({
92
- pattern,
93
- filePath,
94
- routerKind: 'nuxt',
95
- renderingMode: 'ssr-ssg',
96
- isPrivate: false,
97
- inSitemap,
98
- });
99
- }
100
-
101
- return routes;
102
- }
103
-
104
- public async extractRoute(
105
- projectRoot: string,
106
- route: DiscoveredRoute,
107
- fileContent: string,
108
- ): Promise<ExtractedRouteData> {
109
- let extracted = parseVueSfc(route, fileContent);
110
-
111
- // Inherit from default layout if present
112
- const layoutCandidates = ['layouts/default.vue', 'layouts/main.vue'];
113
- extracted = this.layoutResolver.applyInheritance(extracted, layoutCandidates);
114
-
115
- return extracted;
116
- }
117
- }
118
-
1
+ import type { DiscoveredRoute, ExtractedRouteData } from '@seo-partner/core';
2
+ import type { AdapterCapabilities, AdapterVersion, FrameworkAdapter } from './contract.js';
3
+ import { parseVueSfc } from './parsers/vue-sfc-parser.js';
4
+ import { normalizeRoutePattern } from './parsers/route-normalizer.js';
5
+ import { LayoutResolver } from './parsers/layout-resolver.js';
6
+
7
+ export class NuxtAdapter implements FrameworkAdapter {
8
+ public readonly id = 'nuxt';
9
+ public readonly name = 'Nuxt 3';
10
+ public readonly version: AdapterVersion = '1.0';
11
+ public readonly capabilities: AdapterCapabilities = {
12
+ routes: true,
13
+ metadata: true,
14
+ headings: true,
15
+ internalLinks: true,
16
+ jsonLd: true,
17
+ images: true,
18
+ robotsAndSitemap: true,
19
+ };
20
+
21
+ private readonly layoutResolver = new LayoutResolver();
22
+
23
+ public async detect(
24
+ projectRoot: string,
25
+ filePaths: ReadonlyArray<string>,
26
+ contentsByPath: ReadonlyMap<string, string>,
27
+ ): Promise<boolean> {
28
+ for (const filePath of filePaths) {
29
+ const normalized = filePath.replace(/\\/g, '/');
30
+ if (
31
+ normalized === 'nuxt.config.ts' ||
32
+ normalized === 'nuxt.config.js' ||
33
+ normalized === 'nuxt.config.mjs'
34
+ ) {
35
+ return true;
36
+ }
37
+ if (normalized === 'package.json') {
38
+ const content = contentsByPath.get(filePath) ?? '';
39
+ if (content.includes('"nuxt"') || content.includes('"@nuxt/')) {
40
+ return true;
41
+ }
42
+ }
43
+ }
44
+ return false;
45
+ }
46
+
47
+ public async discoverRoutes(
48
+ projectRoot: string,
49
+ filePaths: ReadonlyArray<string>,
50
+ contentsByPath: ReadonlyMap<string, string>,
51
+ sitemapPaths: ReadonlySet<string>,
52
+ ): Promise<ReadonlyArray<DiscoveredRoute>> {
53
+ const routes: DiscoveredRoute[] = [];
54
+
55
+ // Pre-cache layouts from layouts/**
56
+ for (const filePath of filePaths) {
57
+ const normalized = filePath.replace(/\\/g, '/');
58
+ if (normalized.startsWith('layouts/') && normalized.endsWith('.vue')) {
59
+ const content = contentsByPath.get(filePath) ?? '';
60
+ const parsed = parseVueSfc(
61
+ {
62
+ pattern: '',
63
+ filePath,
64
+ routerKind: 'nuxt',
65
+ renderingMode: 'ssr-ssg',
66
+ isPrivate: false,
67
+ inSitemap: false,
68
+ },
69
+ content,
70
+ );
71
+ this.layoutResolver.registerLayout({
72
+ filePath,
73
+ htmlLang: parsed.htmlLang,
74
+ title: parsed.title,
75
+ description: parsed.description,
76
+ canonical: parsed.canonical,
77
+ robots: parsed.robots,
78
+ });
79
+ }
80
+ }
81
+
82
+ for (const filePath of filePaths) {
83
+ const normalized = filePath.replace(/\\/g, '/');
84
+ if (!normalized.startsWith('pages/')) continue;
85
+ if (!normalized.endsWith('.vue')) continue;
86
+
87
+ const relativeInsidePages = normalized.slice('pages/'.length);
88
+ const pattern = normalizeRoutePattern(relativeInsidePages, 'nuxt');
89
+ const inSitemap = sitemapPaths.has(pattern);
90
+
91
+ routes.push({
92
+ pattern,
93
+ filePath,
94
+ routerKind: 'nuxt',
95
+ renderingMode: 'ssr-ssg',
96
+ isPrivate: false,
97
+ inSitemap,
98
+ });
99
+ }
100
+
101
+ return routes;
102
+ }
103
+
104
+ public async extractRoute(
105
+ projectRoot: string,
106
+ route: DiscoveredRoute,
107
+ fileContent: string,
108
+ ): Promise<ExtractedRouteData> {
109
+ let extracted = parseVueSfc(route, fileContent);
110
+
111
+ // Inherit from default layout if present
112
+ const layoutCandidates = ['layouts/default.vue', 'layouts/main.vue'];
113
+ extracted = this.layoutResolver.applyInheritance(extracted, layoutCandidates);
114
+
115
+ return extracted;
116
+ }
117
+ }
118
+
119
+
120
+ export interface NuxtMetaFields {
121
+ title?: string;
122
+ description?: string;
123
+ canonical?: string;
124
+ }
125
+
126
+ export function patchNuxtTemplate(
127
+ sourceCode: string,
128
+ meta: NuxtMetaFields,
129
+ ): string {
130
+ let result = sourceCode;
131
+
132
+ const entries: string[] = [];
133
+ if (meta.title) entries.push(` title: '${meta.title.replace(/'/g, "\\'")}',`);
134
+ if (meta.description) entries.push(` description: '${meta.description.replace(/'/g, "\\'")}',`);
135
+ if (meta.canonical) entries.push(` canonical: '${meta.canonical}',`);
136
+
137
+ const seoMetaCall = `useSeoMeta({\n${entries.join('\n')}\n});\n`;
138
+
139
+ if (/<script\s+setup[^>]*>/i.test(result)) {
140
+ if (!/useSeoMeta\s*\(/i.test(result)) {
141
+ result = result.replace(/(<script\s+setup[^>]*>\s*\n)/i, `$1${seoMetaCall}`);
142
+ }
143
+ } else {
144
+ result = `<script setup lang="ts">\n${seoMetaCall}</script>\n\n` + result;
145
+ }
146
+
147
+ return result;
148
+ }
@@ -0,0 +1,46 @@
1
+ export interface PhpNativeMetaFields {
2
+ title?: string;
3
+ description?: string;
4
+ canonical?: string;
5
+ }
6
+
7
+ export function patchPhpNativeTemplate(
8
+ sourceCode: string,
9
+ meta: PhpNativeMetaFields,
10
+ ): string {
11
+ let result = sourceCode;
12
+
13
+ const vars: string[] = [];
14
+ if (meta.title && !/\$pageTitle\s*=/i.test(result)) {
15
+ vars.push(`$pageTitle = '${meta.title.replace(/'/g, "\\'")}';`);
16
+ }
17
+ if (meta.description && !/\$metaDescription\s*=/i.test(result)) {
18
+ vars.push(`$metaDescription = '${meta.description.replace(/'/g, "\\'")}';`);
19
+ }
20
+ if (meta.canonical && !/\$canonicalUrl\s*=/i.test(result)) {
21
+ vars.push(`$canonicalUrl = '${meta.canonical.replace(/'/g, "\\'")}';`);
22
+ }
23
+
24
+ if (vars.length === 0) {
25
+ return result;
26
+ }
27
+
28
+ const varsBlock = vars.join('\n') + '\n';
29
+
30
+ // If file starts with <?php, inject before any header include
31
+ if (/<\?php/i.test(result)) {
32
+ if (/(include|require|include_once|require_once)\s*['"][^'"]*header[^'"]*['"]/i.test(result)) {
33
+ result = result.replace(
34
+ /((?:include|require|include_once|require_once)\s*['"][^'"]*header[^'"]*['"])/i,
35
+ `${varsBlock}$1`,
36
+ );
37
+ } else {
38
+ result = result.replace(/(<\?php\s*\n)/i, `$1${varsBlock}`);
39
+ }
40
+ } else {
41
+ result = `<?php\n${varsBlock}?>\n` + result;
42
+ }
43
+
44
+ return result;
45
+ }
46
+
@@ -0,0 +1,57 @@
1
+ export interface SitemapRouteEntry {
2
+ pattern: string;
3
+ isPrivate: boolean;
4
+ lastmod?: string;
5
+ changefreq?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
6
+ priority?: number;
7
+ }
8
+
9
+ export function buildSitemapXml(
10
+ routes: ReadonlyArray<SitemapRouteEntry>,
11
+ siteUrl: string,
12
+ ): string {
13
+ const cleanBase = siteUrl.replace(/\/+$/, '');
14
+ const publicRoutes = routes.filter((r) => !r.isPrivate);
15
+
16
+ const urlEntries = publicRoutes.map((r) => {
17
+ const cleanPattern = r.pattern.startsWith('/') ? r.pattern : `/${r.pattern}`;
18
+ const loc = `${cleanBase}${cleanPattern === '/' ? '' : cleanPattern}`;
19
+ const lastmodTag = r.lastmod ? `\n <lastmod>${r.lastmod}</lastmod>` : '';
20
+ const changefreqTag = r.changefreq ? `\n <changefreq>${r.changefreq}</changefreq>` : '';
21
+ const priorityTag = typeof r.priority === 'number' ? `\n <priority>${r.priority.toFixed(1)}</priority>` : '';
22
+
23
+ return ` <url>\n <loc>${loc}</loc>${lastmodTag}${changefreqTag}${priorityTag}\n </url>`;
24
+ });
25
+
26
+ return [
27
+ '<?xml version="1.0" encoding="UTF-8"?>',
28
+ '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
29
+ ...urlEntries,
30
+ '</urlset>',
31
+ ].join('\n');
32
+ }
33
+
34
+ export function buildNextJsAppSitemap(
35
+ routes: ReadonlyArray<SitemapRouteEntry>,
36
+ siteUrl: string,
37
+ ): string {
38
+ const cleanBase = siteUrl.replace(/\/+$/, '');
39
+ const publicRoutes = routes.filter((r) => !r.isPrivate);
40
+
41
+ const items = publicRoutes.map((r) => {
42
+ const cleanPattern = r.pattern.startsWith('/') ? r.pattern : `/${r.pattern}`;
43
+ const loc = `${cleanBase}${cleanPattern === '/' ? '' : cleanPattern}`;
44
+ return ` {\n url: '${loc}',\n lastModified: new Date(),\n changeFrequency: 'weekly',\n priority: ${cleanPattern === '/' ? '1.0' : '0.8'},\n },`;
45
+ });
46
+
47
+ return [
48
+ "import type { MetadataRoute } from 'next';",
49
+ '',
50
+ 'export default function sitemap(): MetadataRoute.Sitemap {',
51
+ ' return [',
52
+ ...items,
53
+ ' ];',
54
+ '}',
55
+ ].join('\n');
56
+ }
57
+
@@ -0,0 +1,56 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ export interface SeoFileInventory {
5
+ robots: {
6
+ appRobotsTs?: string;
7
+ publicRobotsTxt?: string;
8
+ hasConflict: boolean;
9
+ };
10
+ sitemap: {
11
+ appSitemapTs?: string;
12
+ publicSitemapXml?: string;
13
+ hasConflict: boolean;
14
+ };
15
+ }
16
+
17
+ export function detectSeoFileInventory(projectRoot: string): SeoFileInventory {
18
+ const checkExists = (rel: string) => {
19
+ const full = path.join(projectRoot, rel);
20
+ return fs.existsSync(full) ? rel : undefined;
21
+ };
22
+
23
+ const appRobotsTs =
24
+ checkExists('app/robots.ts') ||
25
+ checkExists('app/robots.js') ||
26
+ checkExists('src/app/robots.ts') ||
27
+ checkExists('src/app/robots.js');
28
+
29
+ const publicRobotsTxt =
30
+ checkExists('public/robots.txt') ||
31
+ checkExists('robots.txt');
32
+
33
+ const appSitemapTs =
34
+ checkExists('app/sitemap.ts') ||
35
+ checkExists('app/sitemap.js') ||
36
+ checkExists('src/app/sitemap.ts') ||
37
+ checkExists('src/app/sitemap.js');
38
+
39
+ const publicSitemapXml =
40
+ checkExists('public/sitemap.xml') ||
41
+ checkExists('sitemap.xml');
42
+
43
+ return {
44
+ robots: {
45
+ appRobotsTs,
46
+ publicRobotsTxt,
47
+ hasConflict: Boolean(appRobotsTs && publicRobotsTxt),
48
+ },
49
+ sitemap: {
50
+ appSitemapTs,
51
+ publicSitemapXml,
52
+ hasConflict: Boolean(appSitemapTs && publicSitemapXml),
53
+ },
54
+ };
55
+ }
56
+