@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.
- package/LICENSE +21 -0
- package/dist/ast-merger.d.ts +24 -0
- package/dist/ast-merger.d.ts.map +1 -0
- package/dist/ast-merger.js +85 -0
- package/dist/ast-merger.js.map +1 -0
- package/dist/astro-adapter.d.ts +19 -0
- package/dist/astro-adapter.d.ts.map +1 -0
- package/dist/astro-adapter.js +114 -0
- package/dist/astro-adapter.js.map +1 -0
- package/dist/blog-scaffold-generator.d.ts +8 -0
- package/dist/blog-scaffold-generator.d.ts.map +1 -0
- package/dist/blog-scaffold-generator.js +138 -0
- package/dist/blog-scaffold-generator.js.map +1 -0
- package/dist/contract.d.ts +21 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +2 -0
- package/dist/contract.js.map +1 -0
- package/dist/detector.d.ts +15 -0
- package/dist/detector.d.ts.map +1 -0
- package/dist/detector.js +40 -0
- package/dist/detector.js.map +1 -0
- package/dist/django-adapter.d.ts +13 -0
- package/dist/django-adapter.d.ts.map +1 -0
- package/dist/django-adapter.js +133 -0
- package/dist/django-adapter.js.map +1 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +32 -0
- package/dist/index.js.map +1 -0
- package/dist/laravel-adapter.d.ts +7 -0
- package/dist/laravel-adapter.d.ts.map +1 -0
- package/dist/laravel-adapter.js +35 -0
- package/dist/laravel-adapter.js.map +1 -0
- package/dist/layout-title-resolver.d.ts +8 -0
- package/dist/layout-title-resolver.d.ts.map +1 -0
- package/dist/layout-title-resolver.js +28 -0
- package/dist/layout-title-resolver.js.map +1 -0
- package/dist/nextjs-adapter.d.ts +15 -0
- package/dist/nextjs-adapter.d.ts.map +1 -0
- package/dist/nextjs-adapter.js +83 -0
- package/dist/nextjs-adapter.js.map +1 -0
- package/dist/nuxt-adapter.d.ts +19 -0
- package/dist/nuxt-adapter.d.ts.map +1 -0
- package/dist/nuxt-adapter.js +108 -0
- package/dist/nuxt-adapter.js.map +1 -0
- package/dist/parsers/astro-parser.d.ts +8 -0
- package/dist/parsers/astro-parser.d.ts.map +1 -0
- package/dist/parsers/astro-parser.js +218 -0
- package/dist/parsers/astro-parser.js.map +1 -0
- package/dist/parsers/dtl-parser.d.ts +7 -0
- package/dist/parsers/dtl-parser.d.ts.map +1 -0
- package/dist/parsers/dtl-parser.js +143 -0
- package/dist/parsers/dtl-parser.js.map +1 -0
- package/dist/parsers/html-parser.d.ts +3 -0
- package/dist/parsers/html-parser.d.ts.map +1 -0
- package/dist/parsers/html-parser.js +195 -0
- package/dist/parsers/html-parser.js.map +1 -0
- package/dist/parsers/layout-resolver.d.ts +44 -0
- package/dist/parsers/layout-resolver.d.ts.map +1 -0
- package/dist/parsers/layout-resolver.js +85 -0
- package/dist/parsers/layout-resolver.js.map +1 -0
- package/dist/parsers/php-route-parser.d.ts +11 -0
- package/dist/parsers/php-route-parser.d.ts.map +1 -0
- package/dist/parsers/php-route-parser.js +194 -0
- package/dist/parsers/php-route-parser.js.map +1 -0
- package/dist/parsers/php-template-parser.d.ts +3 -0
- package/dist/parsers/php-template-parser.d.ts.map +1 -0
- package/dist/parsers/php-template-parser.js +230 -0
- package/dist/parsers/php-template-parser.js.map +1 -0
- package/dist/parsers/python-url-parser.d.ts +19 -0
- package/dist/parsers/python-url-parser.d.ts.map +1 -0
- package/dist/parsers/python-url-parser.js +69 -0
- package/dist/parsers/python-url-parser.js.map +1 -0
- package/dist/parsers/remix-parser.d.ts +3 -0
- package/dist/parsers/remix-parser.d.ts.map +1 -0
- package/dist/parsers/remix-parser.js +139 -0
- package/dist/parsers/remix-parser.js.map +1 -0
- package/dist/parsers/robots-sitemap-parser.d.ts +15 -0
- package/dist/parsers/robots-sitemap-parser.d.ts.map +1 -0
- package/dist/parsers/robots-sitemap-parser.js +100 -0
- package/dist/parsers/robots-sitemap-parser.js.map +1 -0
- package/dist/parsers/route-normalizer.d.ts +11 -0
- package/dist/parsers/route-normalizer.d.ts.map +1 -0
- package/dist/parsers/route-normalizer.js +59 -0
- package/dist/parsers/route-normalizer.js.map +1 -0
- package/dist/parsers/svelte-parser.d.ts +3 -0
- package/dist/parsers/svelte-parser.d.ts.map +1 -0
- package/dist/parsers/svelte-parser.js +148 -0
- package/dist/parsers/svelte-parser.js.map +1 -0
- package/dist/parsers/tsx-ast-parser.d.ts +4 -0
- package/dist/parsers/tsx-ast-parser.d.ts.map +1 -0
- package/dist/parsers/tsx-ast-parser.js +380 -0
- package/dist/parsers/tsx-ast-parser.js.map +1 -0
- package/dist/parsers/vue-sfc-parser.d.ts +3 -0
- package/dist/parsers/vue-sfc-parser.d.ts.map +1 -0
- package/dist/parsers/vue-sfc-parser.js +183 -0
- package/dist/parsers/vue-sfc-parser.js.map +1 -0
- package/dist/php-adapter.d.ts +12 -0
- package/dist/php-adapter.d.ts.map +1 -0
- package/dist/php-adapter.js +42 -0
- package/dist/php-adapter.js.map +1 -0
- package/dist/php-native-adapter.d.ts +7 -0
- package/dist/php-native-adapter.d.ts.map +1 -0
- package/dist/php-native-adapter.js +31 -0
- package/dist/php-native-adapter.js.map +1 -0
- package/dist/remix-adapter.d.ts +13 -0
- package/dist/remix-adapter.d.ts.map +1 -0
- package/dist/remix-adapter.js +91 -0
- package/dist/remix-adapter.js.map +1 -0
- package/dist/sitemap-builder.d.ts +10 -0
- package/dist/sitemap-builder.d.ts.map +1 -0
- package/dist/sitemap-builder.js +37 -0
- package/dist/sitemap-builder.js.map +1 -0
- package/dist/skeleton-detector.d.ts +14 -0
- package/dist/skeleton-detector.d.ts.map +1 -0
- package/dist/skeleton-detector.js +33 -0
- package/dist/skeleton-detector.js.map +1 -0
- package/dist/static-adapter.d.ts +12 -0
- package/dist/static-adapter.d.ts.map +1 -0
- package/dist/static-adapter.js +44 -0
- package/dist/static-adapter.js.map +1 -0
- package/dist/sveltekit-adapter.d.ts +19 -0
- package/dist/sveltekit-adapter.d.ts.map +1 -0
- package/dist/sveltekit-adapter.js +115 -0
- package/dist/sveltekit-adapter.js.map +1 -0
- package/dist/symfony-adapter.d.ts +7 -0
- package/dist/symfony-adapter.d.ts.map +1 -0
- package/dist/symfony-adapter.js +23 -0
- package/dist/symfony-adapter.js.map +1 -0
- package/package.json +23 -3
- package/src/ast-merger.ts +123 -0
- package/src/astro-adapter.ts +155 -0
- package/src/blog-scaffold-generator.ts +153 -0
- package/src/contract.ts +40 -0
- package/src/detector.ts +46 -0
- package/src/django-adapter.ts +173 -0
- package/src/index.ts +31 -0
- package/src/laravel-adapter.ts +45 -0
- package/src/layout-title-resolver.ts +37 -0
- package/src/nextjs-adapter.ts +116 -0
- package/src/nuxt-adapter.ts +148 -0
- package/src/parsers/astro-parser.ts +249 -0
- package/src/parsers/dtl-parser.ts +166 -0
- package/src/parsers/html-parser.ts +214 -0
- package/src/parsers/layout-resolver.ts +112 -0
- package/src/parsers/php-route-parser.ts +254 -0
- package/src/parsers/php-template-parser.ts +303 -0
- package/src/parsers/python-url-parser.ts +92 -0
- package/src/parsers/remix-parser.ts +161 -0
- package/src/parsers/robots-sitemap-parser.ts +128 -0
- package/src/parsers/route-normalizer.ts +70 -0
- package/src/parsers/svelte-parser.ts +167 -0
- package/src/parsers/tsx-ast-parser.ts +404 -0
- package/src/parsers/vue-sfc-parser.ts +207 -0
- package/src/php-adapter.ts +68 -0
- package/src/php-native-adapter.ts +46 -0
- package/src/remix-adapter.ts +123 -0
- package/src/sitemap-builder.ts +57 -0
- package/src/skeleton-detector.ts +56 -0
- package/src/static-adapter.ts +62 -0
- package/src/sveltekit-adapter.ts +160 -0
- package/src/symfony-adapter.ts +33 -0
- package/tsconfig.json +9 -0
- package/README.md +0 -3
package/src/index.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
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 './laravel-adapter.js';
|
|
11
|
+
export * from './symfony-adapter.js';
|
|
12
|
+
export * from './php-native-adapter.js';
|
|
13
|
+
export * from './static-adapter.js';
|
|
14
|
+
export * from './parsers/tsx-ast-parser.js';
|
|
15
|
+
export * from './parsers/html-parser.js';
|
|
16
|
+
export * from './parsers/astro-parser.js';
|
|
17
|
+
export * from './parsers/vue-sfc-parser.js';
|
|
18
|
+
export * from './parsers/svelte-parser.js';
|
|
19
|
+
export * from './parsers/remix-parser.js';
|
|
20
|
+
export * from './parsers/python-url-parser.js';
|
|
21
|
+
export * from './parsers/dtl-parser.js';
|
|
22
|
+
export * from './parsers/php-route-parser.js';
|
|
23
|
+
export * from './parsers/php-template-parser.js';
|
|
24
|
+
export * from './parsers/robots-sitemap-parser.js';
|
|
25
|
+
export * from './parsers/route-normalizer.js';
|
|
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, '"')}">`);
|
|
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
|
+
}
|
|
@@ -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,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
|
+
|
|
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,249 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DiscoveredRoute,
|
|
3
|
+
ExtractedRouteData,
|
|
4
|
+
HeadingItem,
|
|
5
|
+
ImageItem,
|
|
6
|
+
JsonLdItem,
|
|
7
|
+
LinkItem,
|
|
8
|
+
} from '@seo-partner/core';
|
|
9
|
+
|
|
10
|
+
function lineNumberAtOffset(content: string, offset: number): number {
|
|
11
|
+
let line = 1;
|
|
12
|
+
for (let i = 0; i < offset && i < content.length; i += 1) {
|
|
13
|
+
if (content.charCodeAt(i) === 10) line += 1;
|
|
14
|
+
}
|
|
15
|
+
return line;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getAttr(attrsRaw: string, attrName: string): string | null {
|
|
19
|
+
const re = new RegExp(`\\b${attrName}\\s*=\\s*["']([^"']*)["']`, 'i');
|
|
20
|
+
const m = re.exec(attrsRaw);
|
|
21
|
+
return m ? (m[1] ?? null) : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface AstroParseResult {
|
|
25
|
+
readonly routeData: ExtractedRouteData;
|
|
26
|
+
readonly layoutReference?: string;
|
|
27
|
+
readonly layoutProps?: Record<string, string>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function parseAstroOrMarkdown(
|
|
31
|
+
route: DiscoveredRoute,
|
|
32
|
+
fileContent: string,
|
|
33
|
+
): AstroParseResult {
|
|
34
|
+
let title: { value: string; line: number } | null = null;
|
|
35
|
+
let description: { value: string; line: number } | null = null;
|
|
36
|
+
let canonical: { value: string; line: number } | null = null;
|
|
37
|
+
let robots: { value: string; line: number } | null = null;
|
|
38
|
+
let htmlLang: { value: string; line: number } | null | undefined = undefined;
|
|
39
|
+
let isDynamic = false;
|
|
40
|
+
let layoutReference: string | undefined = undefined;
|
|
41
|
+
const layoutProps: Record<string, string> = {};
|
|
42
|
+
|
|
43
|
+
const openGraph: Record<string, string> = {};
|
|
44
|
+
const hreflang: Array<{ lang: string; url: string }> = [];
|
|
45
|
+
const headings: HeadingItem[] = [];
|
|
46
|
+
const images: ImageItem[] = [];
|
|
47
|
+
const links: LinkItem[] = [];
|
|
48
|
+
const jsonLdItems: JsonLdItem[] = [];
|
|
49
|
+
|
|
50
|
+
const lines = fileContent.split(/\r?\n/);
|
|
51
|
+
|
|
52
|
+
// 1. Check for YAML frontmatter (Astro / Markdown / MDX)
|
|
53
|
+
const frontmatterMatch = fileContent.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
54
|
+
if (frontmatterMatch && frontmatterMatch[1]) {
|
|
55
|
+
const fmLines = frontmatterMatch[1].split(/\r?\n/);
|
|
56
|
+
for (let i = 0; i < fmLines.length; i += 1) {
|
|
57
|
+
const lineText = (fmLines[i] ?? '').trim();
|
|
58
|
+
const lineNum = i + 2; // 1-based, after opening '---'
|
|
59
|
+
|
|
60
|
+
// YAML: title: "..."
|
|
61
|
+
const yTitle = /^title:\s*["']?(.+?)["']?$/i.exec(lineText);
|
|
62
|
+
if (yTitle && !title) {
|
|
63
|
+
title = { value: (yTitle[1] ?? '').trim(), line: lineNum };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// YAML: description: "..."
|
|
67
|
+
const yDesc = /^description:\s*["']?(.+?)["']?$/i.exec(lineText);
|
|
68
|
+
if (yDesc && !description) {
|
|
69
|
+
description = { value: (yDesc[1] ?? '').trim(), line: lineNum };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// YAML: layout: "..."
|
|
73
|
+
const yLayout = /^layout:\s*["']?(.+?)["']?$/i.exec(lineText);
|
|
74
|
+
if (yLayout && !layoutReference) {
|
|
75
|
+
layoutReference = (yLayout[1] ?? '').trim();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// JS frontmatter: const title = "..." or const title = `...`
|
|
79
|
+
const jsConstTitle = /(?:const|let|var)\s+title\s*=\s*["']([^"']+)["']/i.exec(lineText);
|
|
80
|
+
if (jsConstTitle && !title) {
|
|
81
|
+
title = { value: jsConstTitle[1] ?? '', line: lineNum };
|
|
82
|
+
} else {
|
|
83
|
+
// Dynamic expression: e.g. const title = `...${slug}...`
|
|
84
|
+
const jsDynamicTitle = /(?:const|let|var)\s+title\s*=\s*[`{]/i.exec(lineText);
|
|
85
|
+
if (jsDynamicTitle && !title) {
|
|
86
|
+
title = { value: '[Valeur dynamique]', line: lineNum };
|
|
87
|
+
isDynamic = true;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// JS frontmatter: const description = "..."
|
|
92
|
+
const jsConstDesc = /(?:const|let|var)\s+description\s*=\s*["']([^"']+)["']/i.exec(lineText);
|
|
93
|
+
if (jsConstDesc && !description) {
|
|
94
|
+
description = { value: jsConstDesc[1] ?? '', line: lineNum };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 2. Look for Layout component in template: e.g. <BaseLayout title="My Title" ...>
|
|
100
|
+
const layoutComponentMatch = /<([A-Z][a-zA-Z0-9]*Layout)\b([^>]*?)>/i.exec(fileContent);
|
|
101
|
+
if (layoutComponentMatch) {
|
|
102
|
+
const layoutTag = layoutComponentMatch[1] ?? '';
|
|
103
|
+
layoutReference = layoutTag;
|
|
104
|
+
const attrs = layoutComponentMatch[2] ?? '';
|
|
105
|
+
const line = lineNumberAtOffset(fileContent, layoutComponentMatch.index);
|
|
106
|
+
|
|
107
|
+
// Check title prop: title="..." or title={...}
|
|
108
|
+
const propTitle = getAttr(attrs, 'title');
|
|
109
|
+
if (propTitle && !title) {
|
|
110
|
+
title = { value: propTitle, line };
|
|
111
|
+
} else if (/\btitle=\{([^}]+)\}/i.test(attrs) && !title) {
|
|
112
|
+
// If title={title} and title was already set from frontmatter const, keep it; else dynamic
|
|
113
|
+
if (!title) {
|
|
114
|
+
title = { value: '[Valeur dynamique]', line };
|
|
115
|
+
isDynamic = true;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const propDesc = getAttr(attrs, 'description');
|
|
120
|
+
if (propDesc && !description) {
|
|
121
|
+
description = { value: propDesc, line };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 3. Direct HTML tags: <html lang="...">
|
|
126
|
+
const htmlTagMatch = /<html\b([^>]*?)>/i.exec(fileContent);
|
|
127
|
+
if (htmlTagMatch) {
|
|
128
|
+
const langVal = getAttr(htmlTagMatch[1] ?? '', 'lang');
|
|
129
|
+
if (langVal) {
|
|
130
|
+
htmlLang = {
|
|
131
|
+
value: langVal.trim(),
|
|
132
|
+
line: lineNumberAtOffset(fileContent, htmlTagMatch.index),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Direct <title>
|
|
138
|
+
const titleTagMatch = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(fileContent);
|
|
139
|
+
if (titleTagMatch && !title) {
|
|
140
|
+
const rawVal = (titleTagMatch[1] ?? '').trim();
|
|
141
|
+
const line = lineNumberAtOffset(fileContent, titleTagMatch.index);
|
|
142
|
+
if (/\{[^}]+\}/.test(rawVal)) {
|
|
143
|
+
title = { value: '[Valeur dynamique]', line };
|
|
144
|
+
isDynamic = true;
|
|
145
|
+
} else {
|
|
146
|
+
title = { value: rawVal.replace(/\s+/g, ' '), line };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Direct <meta name="description">, <meta name="robots">, <link rel="canonical">
|
|
151
|
+
const metaRegex = /<meta\b([^>]*?)\/?>/gi;
|
|
152
|
+
let metaMatch: RegExpExecArray | null;
|
|
153
|
+
while ((metaMatch = metaRegex.exec(fileContent)) !== null) {
|
|
154
|
+
const attrs = metaMatch[1] ?? '';
|
|
155
|
+
const line = lineNumberAtOffset(fileContent, metaMatch.index);
|
|
156
|
+
const nameVal = getAttr(attrs, 'name')?.toLowerCase();
|
|
157
|
+
const propVal = getAttr(attrs, 'property')?.toLowerCase();
|
|
158
|
+
const contentVal = getAttr(attrs, 'content');
|
|
159
|
+
if (nameVal === 'description' && contentVal !== null && !description) {
|
|
160
|
+
description = { value: contentVal, line };
|
|
161
|
+
} else if (nameVal === 'robots' && contentVal !== null && !robots) {
|
|
162
|
+
robots = { value: contentVal, line };
|
|
163
|
+
} else if (propVal?.startsWith('og:') && contentVal !== null) {
|
|
164
|
+
openGraph[propVal.slice(3)] = contentVal;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const linkRegex = /<link\b([^>]*?)\/?>/gi;
|
|
169
|
+
let linkMatch: RegExpExecArray | null;
|
|
170
|
+
while ((linkMatch = linkRegex.exec(fileContent)) !== null) {
|
|
171
|
+
const attrs = linkMatch[1] ?? '';
|
|
172
|
+
const line = lineNumberAtOffset(fileContent, linkMatch.index);
|
|
173
|
+
const relVal = getAttr(attrs, 'rel')?.toLowerCase();
|
|
174
|
+
const hrefVal = getAttr(attrs, 'href');
|
|
175
|
+
if (relVal === 'canonical' && hrefVal !== null && !canonical) {
|
|
176
|
+
canonical = { value: hrefVal, line };
|
|
177
|
+
} else if (relVal === 'alternate' && hrefVal !== null) {
|
|
178
|
+
const hl = getAttr(attrs, 'hreflang');
|
|
179
|
+
if (hl) hreflang.push({ lang: hl, url: hrefVal });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 4. Headings: HTML <h1>-<h6> or Markdown #
|
|
184
|
+
const headingRegex = /<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/gi;
|
|
185
|
+
let hMatch: RegExpExecArray | null;
|
|
186
|
+
while ((hMatch = headingRegex.exec(fileContent)) !== null) {
|
|
187
|
+
const level = Number(hMatch[1]) as 1 | 2 | 3 | 4 | 5 | 6;
|
|
188
|
+
const text = (hMatch[2] ?? '').replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
|
|
189
|
+
headings.push({
|
|
190
|
+
level,
|
|
191
|
+
text,
|
|
192
|
+
line: lineNumberAtOffset(fileContent, hMatch.index),
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Markdown headings (# Title)
|
|
197
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
198
|
+
const lineText = lines[i] ?? '';
|
|
199
|
+
const mdHMatch = /^(#{1,6})\s+(.+)$/.exec(lineText.trim());
|
|
200
|
+
if (mdHMatch) {
|
|
201
|
+
const level = (mdHMatch[1]?.length ?? 1) as 1 | 2 | 3 | 4 | 5 | 6;
|
|
202
|
+
headings.push({
|
|
203
|
+
level,
|
|
204
|
+
text: (mdHMatch[2] ?? '').trim(),
|
|
205
|
+
line: i + 1,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// 5. Links: <a href="...">
|
|
211
|
+
const aRegex = /<a\b([^>]*?)>([\s\S]*?)<\/a>/gi;
|
|
212
|
+
let aMatch: RegExpExecArray | null;
|
|
213
|
+
while ((aMatch = aRegex.exec(fileContent)) !== null) {
|
|
214
|
+
const attrs = aMatch[1] ?? '';
|
|
215
|
+
const href = getAttr(attrs, 'href');
|
|
216
|
+
if (href) {
|
|
217
|
+
const anchorText = (aMatch[2] ?? '').replace(/<[^>]+>/g, '').trim();
|
|
218
|
+
const kind = href.startsWith('http://') || href.startsWith('https://') ? 'external' : 'internal';
|
|
219
|
+
links.push({
|
|
220
|
+
toUrl: href,
|
|
221
|
+
anchorText,
|
|
222
|
+
kind,
|
|
223
|
+
rel: getAttr(attrs, 'rel'),
|
|
224
|
+
line: lineNumberAtOffset(fileContent, aMatch.index),
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
routeData: {
|
|
231
|
+
route,
|
|
232
|
+
title,
|
|
233
|
+
description,
|
|
234
|
+
canonical,
|
|
235
|
+
robots,
|
|
236
|
+
htmlLang,
|
|
237
|
+
headings,
|
|
238
|
+
openGraph,
|
|
239
|
+
hreflang,
|
|
240
|
+
images,
|
|
241
|
+
links,
|
|
242
|
+
jsonLdItems,
|
|
243
|
+
isDynamic,
|
|
244
|
+
},
|
|
245
|
+
layoutReference,
|
|
246
|
+
layoutProps,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|