@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
|
@@ -0,0 +1,161 @@
|
|
|
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 function parseRemixRoute(
|
|
25
|
+
route: DiscoveredRoute,
|
|
26
|
+
fileContent: string,
|
|
27
|
+
): ExtractedRouteData {
|
|
28
|
+
let title: { value: string; line: number } | null = null;
|
|
29
|
+
let description: { value: string; line: number } | null = null;
|
|
30
|
+
let canonical: { value: string; line: number } | null = null;
|
|
31
|
+
let robots: { value: string; line: number } | null = null;
|
|
32
|
+
let htmlLang: { value: string; line: number } | null | undefined = undefined;
|
|
33
|
+
let isDynamic = false;
|
|
34
|
+
|
|
35
|
+
const openGraph: Record<string, string> = {};
|
|
36
|
+
const hreflang: Array<{ lang: string; url: string }> = [];
|
|
37
|
+
const headings: HeadingItem[] = [];
|
|
38
|
+
const images: ImageItem[] = [];
|
|
39
|
+
const links: LinkItem[] = [];
|
|
40
|
+
const jsonLdItems: JsonLdItem[] = [];
|
|
41
|
+
|
|
42
|
+
// 1. Check for <html lang="..."> in JSX (e.g. app/root.tsx)
|
|
43
|
+
const htmlTagMatch = /<html\b([^>]*?)>/i.exec(fileContent);
|
|
44
|
+
if (htmlTagMatch) {
|
|
45
|
+
const langVal = getAttr(htmlTagMatch[1] ?? '', 'lang');
|
|
46
|
+
if (langVal) {
|
|
47
|
+
htmlLang = {
|
|
48
|
+
value: langVal.trim(),
|
|
49
|
+
line: lineNumberAtOffset(fileContent, htmlTagMatch.index),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 2. Scan for export const meta = ...
|
|
55
|
+
// Pattern 1: array return in arrow function or array literal
|
|
56
|
+
// export const meta = () => [ ... ]; or export const meta = [ ... ];
|
|
57
|
+
const metaMatch = /export\s+const\s+meta\b[^=]*=\s*(?:(?:\([^)]*\)|[a-zA-Z0-9_]+)\s*=>\s*)?(?:\{[\s\S]*?return\s+)?\[([\s\S]*?)\]/i.exec(fileContent);
|
|
58
|
+
if (metaMatch && metaMatch[1]) {
|
|
59
|
+
const metaArrayContent = metaMatch[1];
|
|
60
|
+
const metaOffset = metaMatch.index;
|
|
61
|
+
const metaLine = lineNumberAtOffset(fileContent, metaOffset);
|
|
62
|
+
|
|
63
|
+
// Extract { title: "..." }
|
|
64
|
+
const titleMatch = /\{\s*title\s*:\s*["']([^"']+)["']\s*\}/i.exec(metaArrayContent);
|
|
65
|
+
if (titleMatch) {
|
|
66
|
+
title = { value: titleMatch[1] ?? '', line: metaLine };
|
|
67
|
+
} else {
|
|
68
|
+
// Dynamic title expression: { title: data?.title || "..." } or { title: data ? ... }
|
|
69
|
+
const fallbackTitle = /\{\s*title\s*:\s*[^"'\n\r}]*?["']([^"']+)["']/i.exec(metaArrayContent);
|
|
70
|
+
if (fallbackTitle) {
|
|
71
|
+
title = { value: fallbackTitle[1] ?? '', line: metaLine };
|
|
72
|
+
} else if (/\{\s*title\s*:/i.test(metaArrayContent)) {
|
|
73
|
+
title = { value: '[Valeur dynamique]', line: metaLine };
|
|
74
|
+
isDynamic = true;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Extract { name: "description", content: "..." }
|
|
79
|
+
const descMatch = /\{\s*name\s*:\s*["']description["']\s*,\s*content\s*:\s*["']([^"']+)["']/i.exec(metaArrayContent);
|
|
80
|
+
if (descMatch) {
|
|
81
|
+
description = { value: descMatch[1] ?? '', line: metaLine };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Extract { name: "robots", content: "..." }
|
|
85
|
+
const robotsMatch = /\{\s*name\s*:\s*["']robots["']\s*,\s*content\s*:\s*["']([^"']+)["']/i.exec(metaArrayContent);
|
|
86
|
+
if (robotsMatch) {
|
|
87
|
+
robots = { value: robotsMatch[1] ?? '', line: metaLine };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Extract canonical: { tagName: "link", rel: "canonical", href: "..." }
|
|
91
|
+
const canonicalMatch = /rel\s*:\s*["']canonical["']\s*,\s*href\s*:\s*["']([^"']+)["']/i.exec(metaArrayContent);
|
|
92
|
+
if (canonicalMatch) {
|
|
93
|
+
canonical = { value: canonicalMatch[1] ?? '', line: metaLine };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 3. Headings in JSX: <h1> to <h6>
|
|
98
|
+
const headingRegex = /<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/gi;
|
|
99
|
+
let hMatch: RegExpExecArray | null;
|
|
100
|
+
while ((hMatch = headingRegex.exec(fileContent)) !== null) {
|
|
101
|
+
const level = Number(hMatch[1]) as 1 | 2 | 3 | 4 | 5 | 6;
|
|
102
|
+
const text = (hMatch[2] ?? '').replace(/<[^>]+>/g, '').replace(/\{[^}]+\}/g, '').replace(/\s+/g, ' ').trim();
|
|
103
|
+
headings.push({
|
|
104
|
+
level,
|
|
105
|
+
text,
|
|
106
|
+
line: lineNumberAtOffset(fileContent, hMatch.index),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 4. Links: <Link to="..."> or <a href="...">
|
|
111
|
+
const linkRegex = /<(?:Link|NavLink)\b([^>]*?)>([\s\S]*?)<\/(?:Link|NavLink)>/gi;
|
|
112
|
+
let lMatch: RegExpExecArray | null;
|
|
113
|
+
while ((lMatch = linkRegex.exec(fileContent)) !== null) {
|
|
114
|
+
const attrs = lMatch[1] ?? '';
|
|
115
|
+
const to = getAttr(attrs, 'to') || getAttr(attrs, 'href');
|
|
116
|
+
if (to) {
|
|
117
|
+
const anchorText = (lMatch[2] ?? '').replace(/<[^>]+>/g, '').trim();
|
|
118
|
+
links.push({
|
|
119
|
+
toUrl: to,
|
|
120
|
+
anchorText,
|
|
121
|
+
kind: to.startsWith('http://') || to.startsWith('https://') ? 'external' : 'internal',
|
|
122
|
+
rel: getAttr(attrs, 'rel'),
|
|
123
|
+
line: lineNumberAtOffset(fileContent, lMatch.index),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const aRegex = /<a\b([^>]*?)>([\s\S]*?)<\/a>/gi;
|
|
129
|
+
let aMatch: RegExpExecArray | null;
|
|
130
|
+
while ((aMatch = aRegex.exec(fileContent)) !== null) {
|
|
131
|
+
const attrs = aMatch[1] ?? '';
|
|
132
|
+
const href = getAttr(attrs, 'href');
|
|
133
|
+
if (href) {
|
|
134
|
+
const anchorText = (aMatch[2] ?? '').replace(/<[^>]+>/g, '').trim();
|
|
135
|
+
links.push({
|
|
136
|
+
toUrl: href,
|
|
137
|
+
anchorText,
|
|
138
|
+
kind: href.startsWith('http://') || href.startsWith('https://') ? 'external' : 'internal',
|
|
139
|
+
rel: getAttr(attrs, 'rel'),
|
|
140
|
+
line: lineNumberAtOffset(fileContent, aMatch.index),
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
route,
|
|
147
|
+
title,
|
|
148
|
+
description,
|
|
149
|
+
canonical,
|
|
150
|
+
robots,
|
|
151
|
+
htmlLang,
|
|
152
|
+
headings,
|
|
153
|
+
openGraph,
|
|
154
|
+
hreflang,
|
|
155
|
+
images,
|
|
156
|
+
links,
|
|
157
|
+
jsonLdItems,
|
|
158
|
+
isDynamic,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
export interface RobotsInfo {
|
|
2
|
+
readonly exists: boolean;
|
|
3
|
+
readonly filePath: string | null;
|
|
4
|
+
readonly hasUserAgent: boolean;
|
|
5
|
+
readonly sitemapUrls: ReadonlyArray<string>;
|
|
6
|
+
readonly disallowPaths: ReadonlyArray<string>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface SitemapInfo {
|
|
10
|
+
readonly exists: boolean;
|
|
11
|
+
readonly filePath: string | null;
|
|
12
|
+
readonly routePaths: ReadonlySet<string>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function parseRobotsFiles(
|
|
16
|
+
filePaths: ReadonlyArray<string>,
|
|
17
|
+
contentsByPath: ReadonlyMap<string, string>,
|
|
18
|
+
): RobotsInfo {
|
|
19
|
+
const robotsPath = filePaths.find(
|
|
20
|
+
(p) =>
|
|
21
|
+
p === 'public/robots.txt' ||
|
|
22
|
+
p === 'robots.txt' ||
|
|
23
|
+
p === 'src/app/robots.ts' ||
|
|
24
|
+
p === 'src/app/robots.js' ||
|
|
25
|
+
p === 'app/robots.ts' ||
|
|
26
|
+
p === 'app/robots.js',
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
if (!robotsPath) {
|
|
30
|
+
return {
|
|
31
|
+
exists: false,
|
|
32
|
+
filePath: null,
|
|
33
|
+
hasUserAgent: false,
|
|
34
|
+
sitemapUrls: [],
|
|
35
|
+
disallowPaths: [],
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const content = contentsByPath.get(robotsPath) ?? '';
|
|
40
|
+
const sitemapUrls: string[] = [];
|
|
41
|
+
const disallowPaths: string[] = [];
|
|
42
|
+
let hasUserAgent = false;
|
|
43
|
+
|
|
44
|
+
if (robotsPath.endsWith('.txt')) {
|
|
45
|
+
for (const line of content.split(/\r?\n/)) {
|
|
46
|
+
const trimmed = line.trim();
|
|
47
|
+
if (/^user-agent:/i.test(trimmed)) {
|
|
48
|
+
hasUserAgent = true;
|
|
49
|
+
} else if (/^sitemap:/i.test(trimmed)) {
|
|
50
|
+
sitemapUrls.push(trimmed.replace(/^sitemap:\s*/i, '').trim());
|
|
51
|
+
} else if (/^disallow:/i.test(trimmed)) {
|
|
52
|
+
const p = trimmed.replace(/^disallow:\s*/i, '').trim();
|
|
53
|
+
if (p) disallowPaths.push(p);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
} else {
|
|
57
|
+
hasUserAgent = /userAgent/i.test(content);
|
|
58
|
+
const smMatch = /sitemap:\s*['"]([^'"]+)['"]/i.exec(content);
|
|
59
|
+
if (smMatch?.[1]) sitemapUrls.push(smMatch[1]);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
exists: true,
|
|
64
|
+
filePath: robotsPath,
|
|
65
|
+
hasUserAgent,
|
|
66
|
+
sitemapUrls,
|
|
67
|
+
disallowPaths,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function parseSitemapFiles(
|
|
72
|
+
filePaths: ReadonlyArray<string>,
|
|
73
|
+
contentsByPath: ReadonlyMap<string, string>,
|
|
74
|
+
): SitemapInfo {
|
|
75
|
+
const sitemapPath = filePaths.find(
|
|
76
|
+
(p) =>
|
|
77
|
+
p === 'public/sitemap.xml' ||
|
|
78
|
+
p === 'sitemap.xml' ||
|
|
79
|
+
p === 'src/app/sitemap.ts' ||
|
|
80
|
+
p === 'src/app/sitemap.js' ||
|
|
81
|
+
p === 'app/sitemap.ts' ||
|
|
82
|
+
p === 'app/sitemap.js',
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
if (!sitemapPath) {
|
|
86
|
+
return {
|
|
87
|
+
exists: false,
|
|
88
|
+
filePath: null,
|
|
89
|
+
routePaths: new Set(),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const content = contentsByPath.get(sitemapPath) ?? '';
|
|
94
|
+
const routePaths = new Set<string>();
|
|
95
|
+
|
|
96
|
+
const locRegex = /<loc>\s*([^<\s]+)\s*<\/loc>/gi;
|
|
97
|
+
let m: RegExpExecArray | null;
|
|
98
|
+
while ((m = locRegex.exec(content)) !== null) {
|
|
99
|
+
const rawUrl = m[1] ?? '';
|
|
100
|
+
try {
|
|
101
|
+
const u = new URL(rawUrl);
|
|
102
|
+
routePaths.add(u.pathname || '/');
|
|
103
|
+
} catch {
|
|
104
|
+
if (rawUrl.startsWith('/')) routePaths.add(rawUrl);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const urlPropRegex = /url:\s*[`'"]([^`'"]+)[`'"]/g;
|
|
109
|
+
while ((m = urlPropRegex.exec(content)) !== null) {
|
|
110
|
+
const rawUrl = m[1] ?? '';
|
|
111
|
+
try {
|
|
112
|
+
const u = new URL(rawUrl);
|
|
113
|
+
routePaths.add(u.pathname || '/');
|
|
114
|
+
} catch {
|
|
115
|
+
const slashIdx = rawUrl.indexOf('/');
|
|
116
|
+
if (slashIdx >= 0) {
|
|
117
|
+
routePaths.add(rawUrl.slice(slashIdx));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
exists: true,
|
|
124
|
+
filePath: sitemapPath,
|
|
125
|
+
routePaths,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { RouterKind } from '@seo-partner/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Normalizes framework-specific route file names and path declarations
|
|
5
|
+
* into a unified standard route pattern:
|
|
6
|
+
* - Dynamic parameter: `:param`
|
|
7
|
+
* - Catch-all parameter: `*rest`
|
|
8
|
+
* - Root path: `/`
|
|
9
|
+
* - Slashes: single leading slash, no trailing slash (unless root `/`)
|
|
10
|
+
*/
|
|
11
|
+
export function normalizeRoutePattern(rawPath: string, routerKind?: RouterKind): string {
|
|
12
|
+
let cleaned = rawPath.replace(/\\/g, '/').trim();
|
|
13
|
+
|
|
14
|
+
// Strip leading folder roots if passed as full file paths
|
|
15
|
+
cleaned = cleaned.replace(/^(?:src\/)?(?:pages|routes|app\/routes)\//i, '');
|
|
16
|
+
|
|
17
|
+
// Strip file extensions if present (.astro, .vue, .svelte, .tsx, .jsx, .md, .mdx, .html, .php)
|
|
18
|
+
cleaned = cleaned.replace(/\.(astro|vue|svelte|tsx|jsx|ts|js|md|mdx|html|php)$/i, '');
|
|
19
|
+
|
|
20
|
+
// Strip route file conventions (e.g. Next.js page, SvelteKit +page, Remix route, index)
|
|
21
|
+
cleaned = cleaned.replace(/(?:^|\/)(?:page|\+page|route|index|_index)$/i, '');
|
|
22
|
+
|
|
23
|
+
if (routerKind === 'remix') {
|
|
24
|
+
// Remix flat route conventions:
|
|
25
|
+
// "blog.$slug" -> "blog/:slug"
|
|
26
|
+
// "docs.$" -> "docs/*"
|
|
27
|
+
// "$slug" -> ":slug"
|
|
28
|
+
// "$" -> "*"
|
|
29
|
+
cleaned = cleaned.replace(/\.\$([a-zA-Z0-9_]+)/g, '/:$1');
|
|
30
|
+
cleaned = cleaned.replace(/\.\$/g, '/*');
|
|
31
|
+
cleaned = cleaned.replace(/^\$([a-zA-Z0-9_]+)/g, ':$1');
|
|
32
|
+
cleaned = cleaned.replace(/^\$$/g, '*');
|
|
33
|
+
cleaned = cleaned.replace(/\./g, '/');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (routerKind === 'django') {
|
|
37
|
+
// Django re_path named groups: (?P<slug>[-\w]+) -> :slug (run before <param> replacement)
|
|
38
|
+
cleaned = cleaned.replace(/\(\?P<([a-zA-Z0-9_]+)>[^)]+\)/g, ':$1');
|
|
39
|
+
// Django path converters: <int:id>, <slug:slug>, <str:username>, <uuid:pk>, <path:subpath>
|
|
40
|
+
cleaned = cleaned.replace(/<path:([a-zA-Z0-9_]+)>/g, '*$1');
|
|
41
|
+
cleaned = cleaned.replace(/<[a-zA-Z0-9_]+:([a-zA-Z0-9_]+)>/g, ':$1');
|
|
42
|
+
cleaned = cleaned.replace(/<([a-zA-Z0-9_]+)>/g, ':$1');
|
|
43
|
+
// Strip regex markers ^ and $
|
|
44
|
+
cleaned = cleaned.replace(/^\^/, '').replace(/\$$/, '');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Next.js, Astro, Nuxt, SvelteKit brackets:
|
|
48
|
+
// Catch-all: [[...rest]] or [...rest] -> *rest
|
|
49
|
+
cleaned = cleaned.replace(/\[\[\.\.\.([a-zA-Z0-9_]+)\]\]/g, '*$1');
|
|
50
|
+
cleaned = cleaned.replace(/\[\.\.\.([a-zA-Z0-9_]+)\]/g, '*$1');
|
|
51
|
+
// Single dynamic: [param] -> :param
|
|
52
|
+
cleaned = cleaned.replace(/\[([a-zA-Z0-9_]+)\]/g, ':$1');
|
|
53
|
+
|
|
54
|
+
// Nuxt 3 catch-all with triple dots or just [...slug]
|
|
55
|
+
cleaned = cleaned.replace(/\[\.\.\.\]/g, '*');
|
|
56
|
+
|
|
57
|
+
// Remove parenthesis group folders: e.g. Next.js / Astro (marketing) -> ignored
|
|
58
|
+
cleaned = cleaned.replace(/\([a-zA-Z0-9_-]+\)\/?/g, '');
|
|
59
|
+
|
|
60
|
+
// Normalize slashes
|
|
61
|
+
cleaned = cleaned.replace(/\/+/g, '/');
|
|
62
|
+
if (!cleaned.startsWith('/')) {
|
|
63
|
+
cleaned = `/${cleaned}`;
|
|
64
|
+
}
|
|
65
|
+
if (cleaned.length > 1 && cleaned.endsWith('/')) {
|
|
66
|
+
cleaned = cleaned.slice(0, -1);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return cleaned || '/';
|
|
70
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
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 function parseSvelteComponent(
|
|
25
|
+
route: DiscoveredRoute,
|
|
26
|
+
fileContent: string,
|
|
27
|
+
): ExtractedRouteData {
|
|
28
|
+
let title: { value: string; line: number } | null = null;
|
|
29
|
+
let description: { value: string; line: number } | null = null;
|
|
30
|
+
let canonical: { value: string; line: number } | null = null;
|
|
31
|
+
let robots: { value: string; line: number } | null = null;
|
|
32
|
+
let htmlLang: { value: string; line: number } | null | undefined = undefined;
|
|
33
|
+
let isDynamic = false;
|
|
34
|
+
|
|
35
|
+
const openGraph: Record<string, string> = {};
|
|
36
|
+
const hreflang: Array<{ lang: string; url: string }> = [];
|
|
37
|
+
const headings: HeadingItem[] = [];
|
|
38
|
+
const images: ImageItem[] = [];
|
|
39
|
+
const links: LinkItem[] = [];
|
|
40
|
+
const jsonLdItems: JsonLdItem[] = [];
|
|
41
|
+
|
|
42
|
+
// 1. <html lang="..."> (common in +layout.svelte or root template)
|
|
43
|
+
const htmlTagMatch = /<html\b([^>]*?)>/i.exec(fileContent);
|
|
44
|
+
if (htmlTagMatch) {
|
|
45
|
+
const langVal = getAttr(htmlTagMatch[1] ?? '', 'lang');
|
|
46
|
+
if (langVal) {
|
|
47
|
+
htmlLang = {
|
|
48
|
+
value: langVal.trim(),
|
|
49
|
+
line: lineNumberAtOffset(fileContent, htmlTagMatch.index),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 2. <svelte:head> block
|
|
55
|
+
const svelteHeadMatch = /<svelte:head>([\s\S]*?)<\/svelte:head>/i.exec(fileContent);
|
|
56
|
+
const headContent = svelteHeadMatch && svelteHeadMatch[1] ? svelteHeadMatch[1] : '';
|
|
57
|
+
const headOffset = svelteHeadMatch ? svelteHeadMatch.index : 0;
|
|
58
|
+
|
|
59
|
+
if (headContent) {
|
|
60
|
+
// Title inside <svelte:head>
|
|
61
|
+
const titleMatch = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(headContent);
|
|
62
|
+
if (titleMatch) {
|
|
63
|
+
const rawVal = (titleMatch[1] ?? '').trim();
|
|
64
|
+
const line = lineNumberAtOffset(fileContent, headOffset + titleMatch.index);
|
|
65
|
+
if (/\{[^}]+\}/.test(rawVal)) {
|
|
66
|
+
title = { value: '[Valeur dynamique]', line };
|
|
67
|
+
isDynamic = true;
|
|
68
|
+
} else {
|
|
69
|
+
title = { value: rawVal.replace(/\s+/g, ' '), line };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Meta tags inside <svelte:head>
|
|
74
|
+
const metaRegex = /<meta\b([^>]*?)\/?>/gi;
|
|
75
|
+
let mMatch: RegExpExecArray | null;
|
|
76
|
+
while ((mMatch = metaRegex.exec(headContent)) !== null) {
|
|
77
|
+
const attrs = mMatch[1] ?? '';
|
|
78
|
+
const line = lineNumberAtOffset(fileContent, headOffset + mMatch.index);
|
|
79
|
+
const nameVal = getAttr(attrs, 'name')?.toLowerCase();
|
|
80
|
+
const propVal = getAttr(attrs, 'property')?.toLowerCase();
|
|
81
|
+
const contentVal = getAttr(attrs, 'content');
|
|
82
|
+
if (nameVal === 'description' && contentVal !== null && !description) {
|
|
83
|
+
description = { value: contentVal, line };
|
|
84
|
+
} else if (nameVal === 'robots' && contentVal !== null && !robots) {
|
|
85
|
+
robots = { value: contentVal, line };
|
|
86
|
+
} else if (propVal?.startsWith('og:') && contentVal !== null) {
|
|
87
|
+
openGraph[propVal.slice(3)] = contentVal;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Canonical link inside <svelte:head>
|
|
92
|
+
const linkRegex = /<link\b([^>]*?)\/?>/gi;
|
|
93
|
+
let lMatch: RegExpExecArray | null;
|
|
94
|
+
while ((lMatch = linkRegex.exec(headContent)) !== null) {
|
|
95
|
+
const attrs = lMatch[1] ?? '';
|
|
96
|
+
const line = lineNumberAtOffset(fileContent, headOffset + lMatch.index);
|
|
97
|
+
const relVal = getAttr(attrs, 'rel')?.toLowerCase();
|
|
98
|
+
const hrefVal = getAttr(attrs, 'href');
|
|
99
|
+
if (relVal === 'canonical' && hrefVal !== null && !canonical) {
|
|
100
|
+
canonical = { value: hrefVal, line };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Fallback direct <title> if outside <svelte:head>
|
|
106
|
+
if (!title) {
|
|
107
|
+
const directTitleMatch = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(fileContent);
|
|
108
|
+
if (directTitleMatch) {
|
|
109
|
+
const rawVal = (directTitleMatch[1] ?? '').trim();
|
|
110
|
+
const line = lineNumberAtOffset(fileContent, directTitleMatch.index);
|
|
111
|
+
if (/\{[^}]+\}/.test(rawVal)) {
|
|
112
|
+
title = { value: '[Valeur dynamique]', line };
|
|
113
|
+
isDynamic = true;
|
|
114
|
+
} else {
|
|
115
|
+
title = { value: rawVal.replace(/\s+/g, ' '), line };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 3. Headings: <h1> to <h6>
|
|
121
|
+
const headingRegex = /<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/gi;
|
|
122
|
+
let hMatch: RegExpExecArray | null;
|
|
123
|
+
while ((hMatch = headingRegex.exec(fileContent)) !== null) {
|
|
124
|
+
const level = Number(hMatch[1]) as 1 | 2 | 3 | 4 | 5 | 6;
|
|
125
|
+
const text = (hMatch[2] ?? '').replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
|
|
126
|
+
headings.push({
|
|
127
|
+
level,
|
|
128
|
+
text,
|
|
129
|
+
line: lineNumberAtOffset(fileContent, hMatch.index),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 4. Links: <a href="...">
|
|
134
|
+
const aRegex = /<a\b([^>]*?)>([\s\S]*?)<\/a>/gi;
|
|
135
|
+
let aMatch: RegExpExecArray | null;
|
|
136
|
+
while ((aMatch = aRegex.exec(fileContent)) !== null) {
|
|
137
|
+
const attrs = aMatch[1] ?? '';
|
|
138
|
+
const href = getAttr(attrs, 'href');
|
|
139
|
+
if (href) {
|
|
140
|
+
const anchorText = (aMatch[2] ?? '').replace(/<[^>]+>/g, '').trim();
|
|
141
|
+
links.push({
|
|
142
|
+
toUrl: href,
|
|
143
|
+
anchorText,
|
|
144
|
+
kind: href.startsWith('http://') || href.startsWith('https://') ? 'external' : 'internal',
|
|
145
|
+
rel: getAttr(attrs, 'rel'),
|
|
146
|
+
line: lineNumberAtOffset(fileContent, aMatch.index),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
route,
|
|
153
|
+
title,
|
|
154
|
+
description,
|
|
155
|
+
canonical,
|
|
156
|
+
robots,
|
|
157
|
+
htmlLang,
|
|
158
|
+
headings,
|
|
159
|
+
openGraph,
|
|
160
|
+
hreflang,
|
|
161
|
+
images,
|
|
162
|
+
links,
|
|
163
|
+
jsonLdItems,
|
|
164
|
+
isDynamic,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|