@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,126 @@
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
+
@@ -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
+
package/src/index.ts ADDED
@@ -0,0 +1,23 @@
1
+ export * from './contract.js';
2
+ export * from './detector.js';
3
+ export * from './nextjs-adapter.js';
4
+ export * from './astro-adapter.js';
5
+ export * from './nuxt-adapter.js';
6
+ export * from './sveltekit-adapter.js';
7
+ export * from './remix-adapter.js';
8
+ export * from './django-adapter.js';
9
+ export * from './php-adapter.js';
10
+ export * from './static-adapter.js';
11
+ export * from './parsers/tsx-ast-parser.js';
12
+ export * from './parsers/html-parser.js';
13
+ export * from './parsers/astro-parser.js';
14
+ export * from './parsers/vue-sfc-parser.js';
15
+ export * from './parsers/svelte-parser.js';
16
+ export * from './parsers/remix-parser.js';
17
+ export * from './parsers/python-url-parser.js';
18
+ export * from './parsers/dtl-parser.js';
19
+ export * from './parsers/php-route-parser.js';
20
+ export * from './parsers/php-template-parser.js';
21
+ export * from './parsers/robots-sitemap-parser.js';
22
+ export * from './parsers/route-normalizer.js';
23
+ export * from './parsers/layout-resolver.js';
@@ -0,0 +1,116 @@
1
+ import type { DiscoveredRoute, ExtractedRouteData } from '@seo-partner/core';
2
+ import type { AdapterCapabilities, FrameworkAdapter } from './contract.js';
3
+ import {
4
+ detectRenderingModeFromTsx,
5
+ parseTsxRouteFile,
6
+ } from './parsers/tsx-ast-parser.js';
7
+
8
+ const PRIVATE_ROUTE_PREFIXES = ['/admin', '/dashboard', '/login', '/auth', '/api', '/account'];
9
+
10
+ export function isPrivateRoutePattern(pattern: string): boolean {
11
+ const lower = pattern.toLowerCase();
12
+ return PRIVATE_ROUTE_PREFIXES.some(
13
+ (prefix) => lower === prefix || lower.startsWith(`${prefix}/`),
14
+ );
15
+ }
16
+
17
+ export function appRouterPathToPattern(relPath: string): string {
18
+ const withoutPrefix = relPath.replace(/^(src\/)?app\//, '');
19
+ const withoutPage = withoutPrefix.replace(/\/?page\.(tsx|ts|jsx|js|mdx)$/, '');
20
+ const segments = withoutPage
21
+ .split('/')
22
+ .filter((s) => s.length > 0 && !(s.startsWith('(') && s.endsWith(')')));
23
+ return segments.length === 0 ? '/' : `/${segments.join('/')}`;
24
+ }
25
+
26
+ export function pagesRouterPathToPattern(relPath: string): string {
27
+ const withoutPrefix = relPath.replace(/^(src\/)?pages\//, '');
28
+ const withoutExt = withoutPrefix.replace(/\.(tsx|ts|jsx|js)$/, '');
29
+ if (withoutExt === 'index') return '/';
30
+ const cleaned = withoutExt.replace(/\/index$/, '');
31
+ return `/${cleaned}`;
32
+ }
33
+
34
+ export class NextJsAdapter implements FrameworkAdapter {
35
+ public readonly id = 'nextjs';
36
+ public readonly name = 'Next.js (App Router & Pages Router)';
37
+ public readonly version = '1.0' as const;
38
+ public readonly capabilities: AdapterCapabilities = {
39
+ routes: true,
40
+ metadata: true,
41
+ headings: true,
42
+ internalLinks: true,
43
+ jsonLd: true,
44
+ images: true,
45
+ robotsAndSitemap: true,
46
+ };
47
+
48
+ public async detect(
49
+ _projectRoot: string,
50
+ filePaths: ReadonlyArray<string>,
51
+ contentsByPath: ReadonlyMap<string, string>,
52
+ ): Promise<boolean> {
53
+ const pkgJson = contentsByPath.get('package.json');
54
+ if (pkgJson && /"next"\s*:/.test(pkgJson)) {
55
+ return true;
56
+ }
57
+ return filePaths.some(
58
+ (p) =>
59
+ /^next\.config\.(js|mjs|ts|cjs)$/.test(p) ||
60
+ /^(src\/)?app\/.*page\.(tsx|ts|jsx|js)$/.test(p) ||
61
+ /^(src\/)?pages\/.*\.(tsx|ts|jsx|js)$/.test(p),
62
+ );
63
+ }
64
+
65
+ public async discoverRoutes(
66
+ _projectRoot: string,
67
+ filePaths: ReadonlyArray<string>,
68
+ contentsByPath: ReadonlyMap<string, string>,
69
+ sitemapPaths: ReadonlySet<string>,
70
+ ): Promise<ReadonlyArray<DiscoveredRoute>> {
71
+ const routes: DiscoveredRoute[] = [];
72
+
73
+ for (const filePath of filePaths) {
74
+ if (/^(src\/)?app\/(.*\/)?page\.(tsx|ts|jsx|js)$/.test(filePath)) {
75
+ const pattern = appRouterPathToPattern(filePath);
76
+ const content = contentsByPath.get(filePath) ?? '';
77
+ routes.push({
78
+ pattern,
79
+ filePath,
80
+ routerKind: 'nextjs-app',
81
+ renderingMode: detectRenderingModeFromTsx(content),
82
+ isPrivate: isPrivateRoutePattern(pattern),
83
+ inSitemap: sitemapPaths.has(pattern),
84
+ });
85
+ } else if (
86
+ /^(src\/)?pages\/.*\.(tsx|ts|jsx|js)$/.test(filePath) &&
87
+ !/\/_app\./.test(filePath) &&
88
+ !/\/_document\./.test(filePath) &&
89
+ !/\/_error\./.test(filePath) &&
90
+ !/^(src\/)?pages\/api\//.test(filePath)
91
+ ) {
92
+ const pattern = pagesRouterPathToPattern(filePath);
93
+ const content = contentsByPath.get(filePath) ?? '';
94
+ routes.push({
95
+ pattern,
96
+ filePath,
97
+ routerKind: 'nextjs-pages',
98
+ renderingMode: detectRenderingModeFromTsx(content),
99
+ isPrivate: isPrivateRoutePattern(pattern),
100
+ inSitemap: sitemapPaths.has(pattern),
101
+ });
102
+ }
103
+ }
104
+
105
+ return routes;
106
+ }
107
+
108
+ public async extractRoute(
109
+ _projectRoot: string,
110
+ route: DiscoveredRoute,
111
+ fileContent: string,
112
+ ): Promise<ExtractedRouteData> {
113
+ return parseTsxRouteFile(route, fileContent);
114
+ }
115
+ }
116
+
@@ -0,0 +1,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
+