@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.
- package/LICENSE +21 -0
- package/dist/astro-adapter.d.ts +13 -0
- package/dist/astro-adapter.d.ts.map +1 -0
- package/dist/astro-adapter.js +95 -0
- package/dist/astro-adapter.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 +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.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 +13 -0
- package/dist/nuxt-adapter.d.ts.map +1 -0
- package/dist/nuxt-adapter.js +88 -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/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/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 +13 -0
- package/dist/sveltekit-adapter.d.ts.map +1 -0
- package/dist/sveltekit-adapter.js +89 -0
- package/dist/sveltekit-adapter.js.map +1 -0
- package/package.json +23 -3
- package/src/astro-adapter.ts +126 -0
- package/src/contract.ts +40 -0
- package/src/detector.ts +46 -0
- package/src/django-adapter.ts +173 -0
- package/src/index.ts +23 -0
- package/src/nextjs-adapter.ts +116 -0
- package/src/nuxt-adapter.ts +118 -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/remix-adapter.ts +123 -0
- package/src/static-adapter.ts +62 -0
- package/src/sveltekit-adapter.ts +119 -0
- package/tsconfig.json +9 -0
- package/README.md +0 -3
|
@@ -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
|
+
|
|
@@ -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
|
+
|