@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,166 @@
|
|
|
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 DtlParseResult {
|
|
25
|
+
readonly routeData: ExtractedRouteData;
|
|
26
|
+
readonly extendsTemplate?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function parseDjangoTemplate(
|
|
30
|
+
route: DiscoveredRoute,
|
|
31
|
+
fileContent: string,
|
|
32
|
+
): DtlParseResult {
|
|
33
|
+
let title: { value: string; line: number } | null = null;
|
|
34
|
+
let description: { value: string; line: number } | null = null;
|
|
35
|
+
let canonical: { value: string; line: number } | null = null;
|
|
36
|
+
let robots: { value: string; line: number } | null = null;
|
|
37
|
+
let htmlLang: { value: string; line: number } | null | undefined = undefined;
|
|
38
|
+
let isDynamic = false;
|
|
39
|
+
let extendsTemplate: string | undefined = undefined;
|
|
40
|
+
|
|
41
|
+
const openGraph: Record<string, string> = {};
|
|
42
|
+
const hreflang: Array<{ lang: string; url: string }> = [];
|
|
43
|
+
const headings: HeadingItem[] = [];
|
|
44
|
+
const images: ImageItem[] = [];
|
|
45
|
+
const links: LinkItem[] = [];
|
|
46
|
+
const jsonLdItems: JsonLdItem[] = [];
|
|
47
|
+
|
|
48
|
+
// 1. Check for {% extends 'base.html' %}
|
|
49
|
+
const extendsMatch = /\{%\s*extends\s+['"]([^'"]+)['"]\s*%\}/i.exec(fileContent);
|
|
50
|
+
if (extendsMatch && extendsMatch[1]) {
|
|
51
|
+
extendsTemplate = extendsMatch[1].replace(/\\/g, '/');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 2. Check for {% block title %}...{% endblock %}
|
|
55
|
+
const blockTitleMatch = /\{%\s*block\s+title\s*%\}([\s\S]*?)\{%\s*endblock\s*(?:title)?\s*%\}/i.exec(fileContent);
|
|
56
|
+
if (blockTitleMatch && blockTitleMatch[1]) {
|
|
57
|
+
const rawTitle = blockTitleMatch[1].trim();
|
|
58
|
+
const line = lineNumberAtOffset(fileContent, blockTitleMatch.index);
|
|
59
|
+
if (/\{\{[^}]+\}\}/.test(rawTitle)) {
|
|
60
|
+
title = { value: '[Valeur dynamique]', line };
|
|
61
|
+
isDynamic = true;
|
|
62
|
+
} else {
|
|
63
|
+
title = { value: rawTitle.replace(/\s+/g, ' '), line };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 3. Check for direct <title>...</title>
|
|
68
|
+
if (!title) {
|
|
69
|
+
const directTitleMatch = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(fileContent);
|
|
70
|
+
if (directTitleMatch && directTitleMatch[1]) {
|
|
71
|
+
const rawTitle = directTitleMatch[1].trim();
|
|
72
|
+
const line = lineNumberAtOffset(fileContent, directTitleMatch.index);
|
|
73
|
+
if (/\{\{[^}]+\}\}/.test(rawTitle) || /\{%/.test(rawTitle)) {
|
|
74
|
+
title = { value: '[Valeur dynamique]', line };
|
|
75
|
+
isDynamic = true;
|
|
76
|
+
} else {
|
|
77
|
+
title = { value: rawTitle.replace(/\s+/g, ' '), line };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 4. Check for <html lang="...">
|
|
83
|
+
const htmlTagMatch = /<html\b([^>]*?)>/i.exec(fileContent);
|
|
84
|
+
if (htmlTagMatch) {
|
|
85
|
+
const langVal = getAttr(htmlTagMatch[1] ?? '', 'lang');
|
|
86
|
+
if (langVal) {
|
|
87
|
+
htmlLang = {
|
|
88
|
+
value: langVal.trim(),
|
|
89
|
+
line: lineNumberAtOffset(fileContent, htmlTagMatch.index),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 5. Meta tags: <meta name="description" ...>
|
|
95
|
+
const metaRegex = /<meta\b([^>]*?)\/?>/gi;
|
|
96
|
+
let metaMatch: RegExpExecArray | null;
|
|
97
|
+
while ((metaMatch = metaRegex.exec(fileContent)) !== null) {
|
|
98
|
+
const attrs = metaMatch[1] ?? '';
|
|
99
|
+
const line = lineNumberAtOffset(fileContent, metaMatch.index);
|
|
100
|
+
const nameVal = getAttr(attrs, 'name')?.toLowerCase();
|
|
101
|
+
const propVal = getAttr(attrs, 'property')?.toLowerCase();
|
|
102
|
+
const contentVal = getAttr(attrs, 'content');
|
|
103
|
+
if (nameVal === 'description' && contentVal !== null && !description) {
|
|
104
|
+
description = { value: contentVal, line };
|
|
105
|
+
} else if (nameVal === 'robots' && contentVal !== null && !robots) {
|
|
106
|
+
robots = { value: contentVal, line };
|
|
107
|
+
} else if (propVal?.startsWith('og:') && contentVal !== null) {
|
|
108
|
+
openGraph[propVal.slice(3)] = contentVal;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 6. Headings: <h1> to <h6>
|
|
113
|
+
const headingRegex = /<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/gi;
|
|
114
|
+
let hMatch: RegExpExecArray | null;
|
|
115
|
+
while ((hMatch = headingRegex.exec(fileContent)) !== null) {
|
|
116
|
+
const level = Number(hMatch[1]) as 1 | 2 | 3 | 4 | 5 | 6;
|
|
117
|
+
const text = (hMatch[2] ?? '')
|
|
118
|
+
.replace(/<[^>]+>/g, '')
|
|
119
|
+
.replace(/\{[{%][\s\S]*?[}%]\}/g, '')
|
|
120
|
+
.replace(/\s+/g, ' ')
|
|
121
|
+
.trim();
|
|
122
|
+
headings.push({
|
|
123
|
+
level,
|
|
124
|
+
text,
|
|
125
|
+
line: lineNumberAtOffset(fileContent, hMatch.index),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 7. Links: <a href="...">
|
|
130
|
+
const aRegex = /<a\b([^>]*?)>([\s\S]*?)<\/a>/gi;
|
|
131
|
+
let aMatch: RegExpExecArray | null;
|
|
132
|
+
while ((aMatch = aRegex.exec(fileContent)) !== null) {
|
|
133
|
+
const attrs = aMatch[1] ?? '';
|
|
134
|
+
const href = getAttr(attrs, 'href');
|
|
135
|
+
if (href) {
|
|
136
|
+
const anchorText = (aMatch[2] ?? '').replace(/<[^>]+>/g, '').trim();
|
|
137
|
+
links.push({
|
|
138
|
+
toUrl: href,
|
|
139
|
+
anchorText,
|
|
140
|
+
kind: href.startsWith('http://') || href.startsWith('https://') ? 'external' : 'internal',
|
|
141
|
+
rel: getAttr(attrs, 'rel'),
|
|
142
|
+
line: lineNumberAtOffset(fileContent, aMatch.index),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
routeData: {
|
|
149
|
+
route,
|
|
150
|
+
title,
|
|
151
|
+
description,
|
|
152
|
+
canonical,
|
|
153
|
+
robots,
|
|
154
|
+
htmlLang,
|
|
155
|
+
headings,
|
|
156
|
+
openGraph,
|
|
157
|
+
hreflang,
|
|
158
|
+
images,
|
|
159
|
+
links,
|
|
160
|
+
jsonLdItems,
|
|
161
|
+
isDynamic,
|
|
162
|
+
},
|
|
163
|
+
extendsTemplate,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
@@ -0,0 +1,214 @@
|
|
|
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 parseStaticHtmlOrMarkdown(
|
|
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
|
+
const openGraph: Record<string, string> = {};
|
|
34
|
+
const hreflang: Array<{ lang: string; url: string }> = [];
|
|
35
|
+
const headings: HeadingItem[] = [];
|
|
36
|
+
const images: ImageItem[] = [];
|
|
37
|
+
const links: LinkItem[] = [];
|
|
38
|
+
const jsonLdItems: JsonLdItem[] = [];
|
|
39
|
+
|
|
40
|
+
if (route.routerKind === 'markdown') {
|
|
41
|
+
const lines = fileContent.split(/\r?\n/);
|
|
42
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
43
|
+
const lineText = lines[i] ?? '';
|
|
44
|
+
const lineNum = i + 1;
|
|
45
|
+
const titleFm = /^title:\s*["']?(.+?)["']?$/i.exec(lineText.trim());
|
|
46
|
+
if (titleFm && !title) {
|
|
47
|
+
title = { value: (titleFm[1] ?? '').trim(), line: lineNum };
|
|
48
|
+
}
|
|
49
|
+
const descFm = /^description:\s*["']?(.+?)["']?$/i.exec(lineText.trim());
|
|
50
|
+
if (descFm && !description) {
|
|
51
|
+
description = { value: (descFm[1] ?? '').trim(), line: lineNum };
|
|
52
|
+
}
|
|
53
|
+
const hMatch = /^(#{1,6})\s+(.+)$/.exec(lineText.trim());
|
|
54
|
+
if (hMatch) {
|
|
55
|
+
const level = (hMatch[1]?.length ?? 1) as 1 | 2 | 3 | 4 | 5 | 6;
|
|
56
|
+
headings.push({ level, text: (hMatch[2] ?? '').trim(), line: lineNum });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
route,
|
|
61
|
+
title,
|
|
62
|
+
description,
|
|
63
|
+
canonical,
|
|
64
|
+
robots,
|
|
65
|
+
headings,
|
|
66
|
+
openGraph,
|
|
67
|
+
hreflang,
|
|
68
|
+
images,
|
|
69
|
+
links,
|
|
70
|
+
jsonLdItems,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const htmlTagMatch = /<html\b([^>]*?)>/i.exec(fileContent);
|
|
75
|
+
if (htmlTagMatch) {
|
|
76
|
+
const langVal = getAttr(htmlTagMatch[1] ?? '', 'lang') ?? '';
|
|
77
|
+
htmlLang = {
|
|
78
|
+
value: langVal.trim(),
|
|
79
|
+
line: lineNumberAtOffset(fileContent, htmlTagMatch.index),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const titleMatch = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(fileContent);
|
|
84
|
+
if (titleMatch) {
|
|
85
|
+
title = {
|
|
86
|
+
value: (titleMatch[1] ?? '').replace(/\s+/g, ' ').trim(),
|
|
87
|
+
line: lineNumberAtOffset(fileContent, titleMatch.index),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const metaRegex = /<meta\b([^>]*?)\/?>/gi;
|
|
92
|
+
let metaMatch: RegExpExecArray | null;
|
|
93
|
+
while ((metaMatch = metaRegex.exec(fileContent)) !== null) {
|
|
94
|
+
const attrs = metaMatch[1] ?? '';
|
|
95
|
+
const line = lineNumberAtOffset(fileContent, metaMatch.index);
|
|
96
|
+
const nameVal = getAttr(attrs, 'name')?.toLowerCase();
|
|
97
|
+
const propVal = getAttr(attrs, 'property')?.toLowerCase();
|
|
98
|
+
const contentVal = getAttr(attrs, 'content');
|
|
99
|
+
if (nameVal === 'description' && contentVal !== null && !description) {
|
|
100
|
+
description = { value: contentVal, line };
|
|
101
|
+
} else if (nameVal === 'robots' && contentVal !== null && !robots) {
|
|
102
|
+
robots = { value: contentVal, line };
|
|
103
|
+
} else if (propVal?.startsWith('og:') && contentVal !== null) {
|
|
104
|
+
openGraph[propVal.slice(3)] = contentVal;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const linkTagRegex = /<link\b([^>]*?)\/?>/gi;
|
|
109
|
+
let linkTagMatch: RegExpExecArray | null;
|
|
110
|
+
while ((linkTagMatch = linkTagRegex.exec(fileContent)) !== null) {
|
|
111
|
+
const attrs = linkTagMatch[1] ?? '';
|
|
112
|
+
const line = lineNumberAtOffset(fileContent, linkTagMatch.index);
|
|
113
|
+
const relVal = getAttr(attrs, 'rel')?.toLowerCase();
|
|
114
|
+
const hrefVal = getAttr(attrs, 'href');
|
|
115
|
+
if (relVal === 'canonical' && hrefVal !== null && !canonical) {
|
|
116
|
+
canonical = { value: hrefVal, line };
|
|
117
|
+
} else if (relVal === 'alternate' && hrefVal !== null) {
|
|
118
|
+
const hl = getAttr(attrs, 'hreflang');
|
|
119
|
+
if (hl) hreflang.push({ lang: hl, url: hrefVal });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const headingRegex = /<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/gi;
|
|
124
|
+
let hMatch: RegExpExecArray | null;
|
|
125
|
+
while ((hMatch = headingRegex.exec(fileContent)) !== null) {
|
|
126
|
+
const level = Number(hMatch[1]) as 1 | 2 | 3 | 4 | 5 | 6;
|
|
127
|
+
const text = (hMatch[2] ?? '').replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
|
|
128
|
+
headings.push({
|
|
129
|
+
level,
|
|
130
|
+
text,
|
|
131
|
+
line: lineNumberAtOffset(fileContent, hMatch.index),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const imgRegex = /<img\b([^>]*?)\/?>/gi;
|
|
136
|
+
let imgMatch: RegExpExecArray | null;
|
|
137
|
+
while ((imgMatch = imgRegex.exec(fileContent)) !== null) {
|
|
138
|
+
const attrs = imgMatch[1] ?? '';
|
|
139
|
+
const srcVal = getAttr(attrs, 'src') ?? '';
|
|
140
|
+
const altVal = getAttr(attrs, 'alt');
|
|
141
|
+
images.push({
|
|
142
|
+
src: srcVal,
|
|
143
|
+
alt: altVal,
|
|
144
|
+
hasAlt: altVal !== null && altVal.trim().length > 0,
|
|
145
|
+
width: getAttr(attrs, 'width'),
|
|
146
|
+
height: getAttr(attrs, 'height'),
|
|
147
|
+
line: lineNumberAtOffset(fileContent, imgMatch.index),
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const anchorRegex = /<a\b([^>]*?)>([\s\S]*?)<\/a>/gi;
|
|
152
|
+
let aMatch: RegExpExecArray | null;
|
|
153
|
+
while ((aMatch = anchorRegex.exec(fileContent)) !== null) {
|
|
154
|
+
const attrs = aMatch[1] ?? '';
|
|
155
|
+
const hrefVal = getAttr(attrs, 'href');
|
|
156
|
+
if (hrefVal) {
|
|
157
|
+
links.push({
|
|
158
|
+
toUrl: hrefVal,
|
|
159
|
+
anchorText: (aMatch[2] ?? '').replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim(),
|
|
160
|
+
kind: /^https?:\/\//i.test(hrefVal) ? 'external' : 'internal',
|
|
161
|
+
rel: getAttr(attrs, 'rel'),
|
|
162
|
+
line: lineNumberAtOffset(fileContent, aMatch.index),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const scriptRegex = /<script\b[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
|
|
168
|
+
let sMatch: RegExpExecArray | null;
|
|
169
|
+
while ((sMatch = scriptRegex.exec(fileContent)) !== null) {
|
|
170
|
+
const rawJson = (sMatch[1] ?? '').trim();
|
|
171
|
+
const line = lineNumberAtOffset(fileContent, sMatch.index);
|
|
172
|
+
try {
|
|
173
|
+
const parsed = JSON.parse(rawJson) as Record<string, unknown>;
|
|
174
|
+
const contextVal = parsed['@context'];
|
|
175
|
+
const typeVal = parsed['@type'];
|
|
176
|
+
const isValid =
|
|
177
|
+
typeof contextVal === 'string' &&
|
|
178
|
+
contextVal.includes('schema.org') &&
|
|
179
|
+
typeof typeVal === 'string' &&
|
|
180
|
+
typeVal.trim().length > 0;
|
|
181
|
+
jsonLdItems.push({
|
|
182
|
+
schemaType: typeof typeVal === 'string' ? typeVal : null,
|
|
183
|
+
rawJson,
|
|
184
|
+
isValid,
|
|
185
|
+
validationError: isValid ? null : 'Missing @context (schema.org) or @type',
|
|
186
|
+
line,
|
|
187
|
+
});
|
|
188
|
+
} catch (err) {
|
|
189
|
+
jsonLdItems.push({
|
|
190
|
+
schemaType: null,
|
|
191
|
+
rawJson,
|
|
192
|
+
isValid: false,
|
|
193
|
+
validationError: err instanceof Error ? err.message : 'Invalid JSON',
|
|
194
|
+
line,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
route,
|
|
201
|
+
title,
|
|
202
|
+
description,
|
|
203
|
+
canonical,
|
|
204
|
+
robots,
|
|
205
|
+
...(htmlLang !== undefined ? { htmlLang } : {}),
|
|
206
|
+
headings,
|
|
207
|
+
openGraph,
|
|
208
|
+
hreflang,
|
|
209
|
+
images,
|
|
210
|
+
links,
|
|
211
|
+
jsonLdItems,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import type { ExtractedRouteData } from '@seo-partner/core';
|
|
3
|
+
|
|
4
|
+
export interface ExtractedLayoutData {
|
|
5
|
+
readonly filePath: string;
|
|
6
|
+
readonly htmlLang?: { readonly value: string; readonly line: number } | null;
|
|
7
|
+
readonly title?: { readonly value: string; readonly line: number } | null;
|
|
8
|
+
readonly description?: { readonly value: string; readonly line: number } | null;
|
|
9
|
+
readonly canonical?: { readonly value: string; readonly line: number } | null;
|
|
10
|
+
readonly robots?: { readonly value: string; readonly line: number } | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class LayoutResolver {
|
|
14
|
+
private readonly layoutsByPath = new Map<string, ExtractedLayoutData>();
|
|
15
|
+
|
|
16
|
+
public registerLayout(layout: ExtractedLayoutData): void {
|
|
17
|
+
const normalized = layout.filePath.replace(/\\/g, '/');
|
|
18
|
+
this.layoutsByPath.set(normalized, layout);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
public getLayout(filePath: string): ExtractedLayoutData | undefined {
|
|
22
|
+
const normalized = filePath.replace(/\\/g, '/');
|
|
23
|
+
return this.layoutsByPath.get(normalized);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Applies layout inheritance to an extracted route if the route lacks metadata
|
|
28
|
+
* (such as htmlLang or fallback title).
|
|
29
|
+
*/
|
|
30
|
+
public applyInheritance(
|
|
31
|
+
routeData: ExtractedRouteData,
|
|
32
|
+
parentLayoutPaths: ReadonlyArray<string>,
|
|
33
|
+
): ExtractedRouteData {
|
|
34
|
+
let htmlLang = routeData.htmlLang;
|
|
35
|
+
let title = routeData.title;
|
|
36
|
+
let description = routeData.description;
|
|
37
|
+
let canonical = routeData.canonical;
|
|
38
|
+
let robots = routeData.robots;
|
|
39
|
+
|
|
40
|
+
// Traverse from nearest layout to root layout
|
|
41
|
+
for (const layoutPath of parentLayoutPaths) {
|
|
42
|
+
const layout = this.getLayout(layoutPath);
|
|
43
|
+
if (!layout) continue;
|
|
44
|
+
|
|
45
|
+
if (!htmlLang && layout.htmlLang) {
|
|
46
|
+
htmlLang = layout.htmlLang;
|
|
47
|
+
}
|
|
48
|
+
if (!title && layout.title) {
|
|
49
|
+
title = layout.title;
|
|
50
|
+
}
|
|
51
|
+
if (!description && layout.description) {
|
|
52
|
+
description = layout.description;
|
|
53
|
+
}
|
|
54
|
+
if (!canonical && layout.canonical) {
|
|
55
|
+
canonical = layout.canonical;
|
|
56
|
+
}
|
|
57
|
+
if (!robots && layout.robots) {
|
|
58
|
+
robots = layout.robots;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
...routeData,
|
|
64
|
+
htmlLang,
|
|
65
|
+
title,
|
|
66
|
+
description,
|
|
67
|
+
canonical,
|
|
68
|
+
robots,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Finds candidate SvelteKit layout paths (+layout.svelte) starting from the page directory
|
|
74
|
+
* up to the routes root directory.
|
|
75
|
+
*/
|
|
76
|
+
public findSvelteKitLayoutPaths(pageFilePath: string): ReadonlyArray<string> {
|
|
77
|
+
const normalized = pageFilePath.replace(/\\/g, '/');
|
|
78
|
+
const parts = normalized.split('/');
|
|
79
|
+
const routesIdx = parts.lastIndexOf('routes');
|
|
80
|
+
if (routesIdx === -1) return [];
|
|
81
|
+
|
|
82
|
+
const candidates: string[] = [];
|
|
83
|
+
const dirParts = parts.slice(0, -1);
|
|
84
|
+
|
|
85
|
+
// From current dir up to routes directory
|
|
86
|
+
for (let i = dirParts.length; i >= routesIdx; i--) {
|
|
87
|
+
const dir = dirParts.slice(0, i).join('/');
|
|
88
|
+
candidates.push(`${dir}/+layout.svelte`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return candidates;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Resolves Django base template paths (e.g. "base.html" or "layouts/base.html").
|
|
96
|
+
*/
|
|
97
|
+
public findDjangoExtendsPath(
|
|
98
|
+
templateContent: string,
|
|
99
|
+
allTemplatePaths: ReadonlyArray<string>,
|
|
100
|
+
): string | undefined {
|
|
101
|
+
const extendsMatch = templateContent.match(/\{%\s*extends\s+['"]([^'"]+)['"]\s*%\}/i);
|
|
102
|
+
if (!extendsMatch || !extendsMatch[1]) return undefined;
|
|
103
|
+
|
|
104
|
+
const baseName = extendsMatch[1].replace(/\\/g, '/');
|
|
105
|
+
// Match against known templates
|
|
106
|
+
return allTemplatePaths.find((p) => {
|
|
107
|
+
const norm = p.replace(/\\/g, '/');
|
|
108
|
+
return norm.endsWith(baseName) || norm === baseName;
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|