@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,404 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
import type {
|
|
3
|
+
DiscoveredRoute,
|
|
4
|
+
ExtractedRouteData,
|
|
5
|
+
HeadingItem,
|
|
6
|
+
ImageItem,
|
|
7
|
+
JsonLdItem,
|
|
8
|
+
LinkItem,
|
|
9
|
+
RenderingMode,
|
|
10
|
+
} from '@seo-partner/core';
|
|
11
|
+
|
|
12
|
+
export function detectRenderingModeFromTsx(sourceText: string): RenderingMode {
|
|
13
|
+
const trimmed = sourceText.trimStart();
|
|
14
|
+
if (trimmed.startsWith("'use client'") || trimmed.startsWith('"use client"')) {
|
|
15
|
+
return 'client-only';
|
|
16
|
+
}
|
|
17
|
+
if (/export\s+const\s+dynamic\s*=\s*['"]force-dynamic['"]/.test(sourceText)) {
|
|
18
|
+
return 'dynamic';
|
|
19
|
+
}
|
|
20
|
+
return 'ssr-ssg';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function getLineNumber(sourceFile: ts.SourceFile, pos: number): number {
|
|
24
|
+
return sourceFile.getLineAndCharacterOfPosition(pos).line + 1;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function extractStringLiteral(node: ts.Node | undefined): string | null {
|
|
28
|
+
if (!node) return null;
|
|
29
|
+
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
|
30
|
+
return node.text;
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function extractJsxText(node: ts.JsxElement | ts.JsxSelfClosingElement | ts.JsxFragment): string {
|
|
36
|
+
if (ts.isJsxSelfClosingElement(node)) return '';
|
|
37
|
+
const parts: string[] = [];
|
|
38
|
+
for (const child of node.children) {
|
|
39
|
+
if (ts.isJsxText(child)) {
|
|
40
|
+
const cleaned = child.getText().replace(/\s+/g, ' ').trim();
|
|
41
|
+
if (cleaned) parts.push(cleaned);
|
|
42
|
+
} else if (ts.isJsxExpression(child) && child.expression) {
|
|
43
|
+
const lit = extractStringLiteral(child.expression);
|
|
44
|
+
if (lit) parts.push(lit);
|
|
45
|
+
} else if (ts.isJsxElement(child) || ts.isJsxFragment(child)) {
|
|
46
|
+
const nested = extractJsxText(child);
|
|
47
|
+
if (nested) parts.push(nested);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return parts.join(' ').trim();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function validateJsonLdObject(
|
|
54
|
+
rawJson: string,
|
|
55
|
+
line: number,
|
|
56
|
+
): JsonLdItem {
|
|
57
|
+
try {
|
|
58
|
+
const parsed = JSON.parse(rawJson) as Record<string, unknown>;
|
|
59
|
+
const contextVal = parsed['@context'];
|
|
60
|
+
const typeVal = parsed['@type'];
|
|
61
|
+
const hasValidContext =
|
|
62
|
+
typeof contextVal === 'string' && contextVal.includes('schema.org');
|
|
63
|
+
const hasValidType =
|
|
64
|
+
typeof typeVal === 'string' && typeVal.trim().length > 0;
|
|
65
|
+
|
|
66
|
+
if (!hasValidContext) {
|
|
67
|
+
return {
|
|
68
|
+
schemaType: typeof typeVal === 'string' ? typeVal : null,
|
|
69
|
+
rawJson,
|
|
70
|
+
isValid: false,
|
|
71
|
+
validationError: 'Missing or invalid @context (expected https://schema.org)',
|
|
72
|
+
line,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
if (!hasValidType) {
|
|
76
|
+
return {
|
|
77
|
+
schemaType: null,
|
|
78
|
+
rawJson,
|
|
79
|
+
isValid: false,
|
|
80
|
+
validationError: 'Missing required @type property in JSON-LD object',
|
|
81
|
+
line,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
schemaType: typeVal,
|
|
86
|
+
rawJson,
|
|
87
|
+
isValid: true,
|
|
88
|
+
validationError: null,
|
|
89
|
+
line,
|
|
90
|
+
};
|
|
91
|
+
} catch (err) {
|
|
92
|
+
return {
|
|
93
|
+
schemaType: null,
|
|
94
|
+
rawJson,
|
|
95
|
+
isValid: false,
|
|
96
|
+
validationError: err instanceof Error ? err.message : 'Invalid JSON syntax',
|
|
97
|
+
line,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function objectLiteralToPlainObject(
|
|
103
|
+
obj: ts.ObjectLiteralExpression,
|
|
104
|
+
): Record<string, unknown> {
|
|
105
|
+
const result: Record<string, unknown> = {};
|
|
106
|
+
for (const prop of obj.properties) {
|
|
107
|
+
if (ts.isPropertyAssignment(prop)) {
|
|
108
|
+
const key = ts.isIdentifier(prop.name)
|
|
109
|
+
? prop.name.text
|
|
110
|
+
: extractStringLiteral(prop.name) ?? prop.name.getText();
|
|
111
|
+
const val = prop.initializer;
|
|
112
|
+
const strVal = extractStringLiteral(val);
|
|
113
|
+
if (strVal !== null) {
|
|
114
|
+
result[key] = strVal;
|
|
115
|
+
} else if (val.kind === ts.SyntaxKind.TrueKeyword) {
|
|
116
|
+
result[key] = true;
|
|
117
|
+
} else if (val.kind === ts.SyntaxKind.FalseKeyword) {
|
|
118
|
+
result[key] = false;
|
|
119
|
+
} else if (ts.isNumericLiteral(val)) {
|
|
120
|
+
result[key] = Number(val.text);
|
|
121
|
+
} else if (ts.isObjectLiteralExpression(val)) {
|
|
122
|
+
result[key] = objectLiteralToPlainObject(val);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return result;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function parseTsxRouteFile(
|
|
130
|
+
route: DiscoveredRoute,
|
|
131
|
+
fileContent: string,
|
|
132
|
+
): ExtractedRouteData {
|
|
133
|
+
const sourceFile = ts.createSourceFile(
|
|
134
|
+
route.filePath,
|
|
135
|
+
fileContent,
|
|
136
|
+
ts.ScriptTarget.Latest,
|
|
137
|
+
true,
|
|
138
|
+
ts.ScriptKind.TSX,
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
let title: { value: string; line: number } | null = null;
|
|
142
|
+
let description: { value: string; line: number } | null = null;
|
|
143
|
+
let canonical: { value: string; line: number } | null = null;
|
|
144
|
+
let robots: { value: string; line: number } | null = null;
|
|
145
|
+
let htmlLang: { value: string; line: number } | null | undefined = undefined;
|
|
146
|
+
const openGraph: Record<string, string> = {};
|
|
147
|
+
const hreflang: Array<{ lang: string; url: string }> = [];
|
|
148
|
+
const headings: HeadingItem[] = [];
|
|
149
|
+
const images: ImageItem[] = [];
|
|
150
|
+
const links: LinkItem[] = [];
|
|
151
|
+
const jsonLdItems: JsonLdItem[] = [];
|
|
152
|
+
|
|
153
|
+
const varObjectMap = new Map<string, { obj: Record<string, unknown>; line: number }>();
|
|
154
|
+
|
|
155
|
+
function inspectMetadataObject(obj: ts.ObjectLiteralExpression): void {
|
|
156
|
+
for (const prop of obj.properties) {
|
|
157
|
+
if (!ts.isPropertyAssignment(prop)) continue;
|
|
158
|
+
const propName = ts.isIdentifier(prop.name)
|
|
159
|
+
? prop.name.text
|
|
160
|
+
: extractStringLiteral(prop.name) ?? '';
|
|
161
|
+
const line = getLineNumber(sourceFile, prop.getStart(sourceFile));
|
|
162
|
+
|
|
163
|
+
if (propName === 'title') {
|
|
164
|
+
const str = extractStringLiteral(prop.initializer);
|
|
165
|
+
if (str !== null) {
|
|
166
|
+
title = { value: str, line };
|
|
167
|
+
} else if (ts.isObjectLiteralExpression(prop.initializer)) {
|
|
168
|
+
const plain = objectLiteralToPlainObject(prop.initializer);
|
|
169
|
+
if (typeof plain['default'] === 'string') {
|
|
170
|
+
title = { value: plain['default'], line };
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
} else if (propName === 'description') {
|
|
174
|
+
const str = extractStringLiteral(prop.initializer);
|
|
175
|
+
if (str !== null) {
|
|
176
|
+
description = { value: str, line };
|
|
177
|
+
}
|
|
178
|
+
} else if (propName === 'alternates' && ts.isObjectLiteralExpression(prop.initializer)) {
|
|
179
|
+
for (const altProp of prop.initializer.properties) {
|
|
180
|
+
if (!ts.isPropertyAssignment(altProp)) continue;
|
|
181
|
+
const altName = ts.isIdentifier(altProp.name) ? altProp.name.text : '';
|
|
182
|
+
const altLine = getLineNumber(sourceFile, altProp.getStart(sourceFile));
|
|
183
|
+
if (altName === 'canonical') {
|
|
184
|
+
const cStr = extractStringLiteral(altProp.initializer);
|
|
185
|
+
if (cStr !== null) canonical = { value: cStr, line: altLine };
|
|
186
|
+
} else if (
|
|
187
|
+
altName === 'languages' &&
|
|
188
|
+
ts.isObjectLiteralExpression(altProp.initializer)
|
|
189
|
+
) {
|
|
190
|
+
const langs = objectLiteralToPlainObject(altProp.initializer);
|
|
191
|
+
for (const [k, v] of Object.entries(langs)) {
|
|
192
|
+
if (typeof v === 'string') hreflang.push({ lang: k, url: v });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
} else if (propName === 'robots') {
|
|
197
|
+
const str = extractStringLiteral(prop.initializer);
|
|
198
|
+
if (str !== null) {
|
|
199
|
+
robots = { value: str, line };
|
|
200
|
+
} else if (ts.isObjectLiteralExpression(prop.initializer)) {
|
|
201
|
+
const plain = objectLiteralToPlainObject(prop.initializer);
|
|
202
|
+
const parts: string[] = [];
|
|
203
|
+
if (plain['index'] === false) parts.push('noindex');
|
|
204
|
+
if (plain['index'] === true) parts.push('index');
|
|
205
|
+
if (plain['follow'] === false) parts.push('nofollow');
|
|
206
|
+
if (plain['follow'] === true) parts.push('follow');
|
|
207
|
+
robots = { value: parts.join(', ') || JSON.stringify(plain), line };
|
|
208
|
+
}
|
|
209
|
+
} else if (propName === 'openGraph' && ts.isObjectLiteralExpression(prop.initializer)) {
|
|
210
|
+
const plain = objectLiteralToPlainObject(prop.initializer);
|
|
211
|
+
for (const [k, v] of Object.entries(plain)) {
|
|
212
|
+
if (typeof v === 'string') openGraph[k] = v;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function getJsxAttributeMap(
|
|
219
|
+
attrs: ts.JsxAttributes,
|
|
220
|
+
): Map<string, { value: string | null; expr: ts.Expression | null; line: number }> {
|
|
221
|
+
const map = new Map<
|
|
222
|
+
string,
|
|
223
|
+
{ value: string | null; expr: ts.Expression | null; line: number }
|
|
224
|
+
>();
|
|
225
|
+
for (const prop of attrs.properties) {
|
|
226
|
+
if (ts.isJsxAttribute(prop)) {
|
|
227
|
+
const name = prop.name.getText(sourceFile);
|
|
228
|
+
const line = getLineNumber(sourceFile, prop.getStart(sourceFile));
|
|
229
|
+
if (!prop.initializer) {
|
|
230
|
+
map.set(name, { value: '', expr: null, line });
|
|
231
|
+
} else if (ts.isStringLiteral(prop.initializer)) {
|
|
232
|
+
map.set(name, { value: prop.initializer.text, expr: null, line });
|
|
233
|
+
} else if (
|
|
234
|
+
ts.isJsxExpression(prop.initializer) &&
|
|
235
|
+
prop.initializer.expression
|
|
236
|
+
) {
|
|
237
|
+
const lit = extractStringLiteral(prop.initializer.expression);
|
|
238
|
+
map.set(name, {
|
|
239
|
+
value: lit,
|
|
240
|
+
expr: prop.initializer.expression,
|
|
241
|
+
line,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return map;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function visit(node: ts.Node): void {
|
|
250
|
+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
|
|
251
|
+
const varName = node.name.text;
|
|
252
|
+
if (ts.isObjectLiteralExpression(node.initializer)) {
|
|
253
|
+
if (varName === 'metadata') {
|
|
254
|
+
inspectMetadataObject(node.initializer);
|
|
255
|
+
} else {
|
|
256
|
+
varObjectMap.set(varName, {
|
|
257
|
+
obj: objectLiteralToPlainObject(node.initializer),
|
|
258
|
+
line: getLineNumber(sourceFile, node.getStart(sourceFile)),
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (
|
|
265
|
+
ts.isFunctionDeclaration(node) &&
|
|
266
|
+
node.name?.text === 'generateMetadata' &&
|
|
267
|
+
node.body
|
|
268
|
+
) {
|
|
269
|
+
node.body.forEachChild(function walkBody(child) {
|
|
270
|
+
if (ts.isReturnStatement(child) && child.expression && ts.isObjectLiteralExpression(child.expression)) {
|
|
271
|
+
inspectMetadataObject(child.expression);
|
|
272
|
+
}
|
|
273
|
+
child.forEachChild(walkBody);
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) {
|
|
278
|
+
const opening = ts.isJsxElement(node) ? node.openingElement : node;
|
|
279
|
+
const tagName = opening.tagName.getText(sourceFile);
|
|
280
|
+
const line = getLineNumber(sourceFile, opening.getStart(sourceFile));
|
|
281
|
+
const attrs = getJsxAttributeMap(opening.attributes);
|
|
282
|
+
|
|
283
|
+
if (tagName === 'html') {
|
|
284
|
+
const langAttr = attrs.get('lang');
|
|
285
|
+
htmlLang = { value: langAttr?.value ?? '', line };
|
|
286
|
+
} else if (/^h[1-6]$/i.test(tagName)) {
|
|
287
|
+
const level = Number(tagName.slice(1)) as 1 | 2 | 3 | 4 | 5 | 6;
|
|
288
|
+
const text = extractJsxText(node);
|
|
289
|
+
headings.push({ level, text, line });
|
|
290
|
+
} else if (tagName === 'title' && ts.isJsxElement(node)) {
|
|
291
|
+
const text = extractJsxText(node);
|
|
292
|
+
if (text && !title) {
|
|
293
|
+
title = { value: text, line };
|
|
294
|
+
}
|
|
295
|
+
} else if (tagName === 'meta') {
|
|
296
|
+
const nameAttr = attrs.get('name')?.value?.toLowerCase();
|
|
297
|
+
const propAttr = attrs.get('property')?.value?.toLowerCase();
|
|
298
|
+
const contentAttr = attrs.get('content')?.value ?? null;
|
|
299
|
+
if (nameAttr === 'description' && contentAttr !== null && !description) {
|
|
300
|
+
description = { value: contentAttr, line };
|
|
301
|
+
} else if (nameAttr === 'robots' && contentAttr !== null && !robots) {
|
|
302
|
+
robots = { value: contentAttr, line };
|
|
303
|
+
} else if (propAttr?.startsWith('og:') && contentAttr !== null) {
|
|
304
|
+
openGraph[propAttr.slice(3)] = contentAttr;
|
|
305
|
+
}
|
|
306
|
+
} else if (tagName === 'link') {
|
|
307
|
+
const relAttr = attrs.get('rel')?.value?.toLowerCase();
|
|
308
|
+
const hrefAttr = attrs.get('href')?.value ?? null;
|
|
309
|
+
if (relAttr === 'canonical' && hrefAttr !== null && !canonical) {
|
|
310
|
+
canonical = { value: hrefAttr, line };
|
|
311
|
+
} else if (relAttr === 'alternate' && hrefAttr !== null) {
|
|
312
|
+
const hrefLangVal = attrs.get('hrefLang')?.value ?? attrs.get('hreflang')?.value;
|
|
313
|
+
if (hrefLangVal) {
|
|
314
|
+
hreflang.push({ lang: hrefLangVal, url: hrefAttr });
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
} else if (tagName === 'img' || tagName === 'Image') {
|
|
318
|
+
const srcVal = attrs.get('src')?.value ?? '';
|
|
319
|
+
const altEntry = attrs.get('alt');
|
|
320
|
+
const hasAlt = altEntry !== undefined && altEntry.value !== null && altEntry.value.trim().length > 0;
|
|
321
|
+
images.push({
|
|
322
|
+
src: srcVal,
|
|
323
|
+
alt: altEntry?.value ?? null,
|
|
324
|
+
hasAlt,
|
|
325
|
+
width: attrs.get('width')?.value ?? null,
|
|
326
|
+
height: attrs.get('height')?.value ?? null,
|
|
327
|
+
line,
|
|
328
|
+
});
|
|
329
|
+
} else if (tagName === 'a' || tagName === 'Link') {
|
|
330
|
+
const hrefVal = attrs.get('href')?.value;
|
|
331
|
+
if (hrefVal) {
|
|
332
|
+
const isExternal = /^https?:\/\//i.test(hrefVal);
|
|
333
|
+
links.push({
|
|
334
|
+
toUrl: hrefVal,
|
|
335
|
+
anchorText: extractJsxText(node),
|
|
336
|
+
kind: isExternal ? 'external' : 'internal',
|
|
337
|
+
rel: attrs.get('rel')?.value ?? null,
|
|
338
|
+
line,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
} else if (tagName === 'script') {
|
|
342
|
+
const typeVal = attrs.get('type')?.value;
|
|
343
|
+
if (typeVal === 'application/ld+json') {
|
|
344
|
+
const dangerAttr = attrs.get('dangerouslySetInnerHTML');
|
|
345
|
+
if (dangerAttr?.expr && ts.isObjectLiteralExpression(dangerAttr.expr)) {
|
|
346
|
+
for (const p of dangerAttr.expr.properties) {
|
|
347
|
+
if (
|
|
348
|
+
ts.isPropertyAssignment(p) &&
|
|
349
|
+
p.name.getText(sourceFile) === '__html'
|
|
350
|
+
) {
|
|
351
|
+
const strLit = extractStringLiteral(p.initializer);
|
|
352
|
+
if (strLit !== null) {
|
|
353
|
+
jsonLdItems.push(validateJsonLdObject(strLit, line));
|
|
354
|
+
} else if (
|
|
355
|
+
ts.isCallExpression(p.initializer) &&
|
|
356
|
+
p.initializer.expression.getText(sourceFile) === 'JSON.stringify' &&
|
|
357
|
+
p.initializer.arguments.length > 0
|
|
358
|
+
) {
|
|
359
|
+
const arg0 = p.initializer.arguments[0]!;
|
|
360
|
+
if (ts.isObjectLiteralExpression(arg0)) {
|
|
361
|
+
const plain = objectLiteralToPlainObject(arg0);
|
|
362
|
+
jsonLdItems.push(
|
|
363
|
+
validateJsonLdObject(JSON.stringify(plain), line),
|
|
364
|
+
);
|
|
365
|
+
} else if (ts.isIdentifier(arg0)) {
|
|
366
|
+
const resolved = varObjectMap.get(arg0.text);
|
|
367
|
+
if (resolved) {
|
|
368
|
+
jsonLdItems.push(
|
|
369
|
+
validateJsonLdObject(
|
|
370
|
+
JSON.stringify(resolved.obj),
|
|
371
|
+
resolved.line,
|
|
372
|
+
),
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
ts.forEachChild(node, visit);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
visit(sourceFile);
|
|
388
|
+
|
|
389
|
+
return {
|
|
390
|
+
route,
|
|
391
|
+
title,
|
|
392
|
+
description,
|
|
393
|
+
canonical,
|
|
394
|
+
robots,
|
|
395
|
+
...(htmlLang !== undefined ? { htmlLang } : {}),
|
|
396
|
+
headings,
|
|
397
|
+
openGraph,
|
|
398
|
+
hreflang,
|
|
399
|
+
images,
|
|
400
|
+
links,
|
|
401
|
+
jsonLdItems,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
@@ -0,0 +1,207 @@
|
|
|
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 parseVueSfc(
|
|
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. Scan script block: <script setup> or <script>
|
|
43
|
+
const scriptMatch = /<script\b[^>]*>([\s\S]*?)<\/script>/i.exec(fileContent);
|
|
44
|
+
if (scriptMatch && scriptMatch[1]) {
|
|
45
|
+
const scriptContent = scriptMatch[1];
|
|
46
|
+
const scriptOffset = scriptMatch.index;
|
|
47
|
+
|
|
48
|
+
// useSeoMeta({ ... })
|
|
49
|
+
const useSeoMetaMatch = /useSeoMeta\s*\(\s*\{([\s\S]*?)\}\s*\)/i.exec(scriptContent);
|
|
50
|
+
if (useSeoMetaMatch && useSeoMetaMatch[1]) {
|
|
51
|
+
const metaBody = useSeoMetaMatch[1];
|
|
52
|
+
const callLine = lineNumberAtOffset(fileContent, scriptOffset + useSeoMetaMatch.index);
|
|
53
|
+
|
|
54
|
+
// title: '...' or title: "..."
|
|
55
|
+
const titleProp = /title\s*:\s*["']([^"']+)["']/i.exec(metaBody);
|
|
56
|
+
if (titleProp && !title) {
|
|
57
|
+
title = { value: titleProp[1] ?? '', line: callLine };
|
|
58
|
+
} else if (/title\s*:\s*[`()]/i.test(metaBody) && !title) {
|
|
59
|
+
title = { value: '[Valeur dynamique]', line: callLine };
|
|
60
|
+
isDynamic = true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// description: '...'
|
|
64
|
+
const descProp = /description\s*:\s*["']([^"']+)["']/i.exec(metaBody);
|
|
65
|
+
if (descProp && !description) {
|
|
66
|
+
description = { value: descProp[1] ?? '', line: callLine };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// robots: '...'
|
|
70
|
+
const robotsProp = /robots\s*:\s*["']([^"']+)["']/i.exec(metaBody);
|
|
71
|
+
if (robotsProp && !robots) {
|
|
72
|
+
robots = { value: robotsProp[1] ?? '', line: callLine };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ogTitle, ogDescription
|
|
76
|
+
const ogTitleProp = /ogTitle\s*:\s*["']([^"']+)["']/i.exec(metaBody);
|
|
77
|
+
if (ogTitleProp) openGraph['title'] = ogTitleProp[1] ?? '';
|
|
78
|
+
const ogDescProp = /ogDescription\s*:\s*["']([^"']+)["']/i.exec(metaBody);
|
|
79
|
+
if (ogDescProp) openGraph['description'] = ogDescProp[1] ?? '';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// useHead({ ... })
|
|
83
|
+
const useHeadMatch = /useHead\s*\(\s*\{([\s\S]*?)\}\s*\)/i.exec(scriptContent);
|
|
84
|
+
if (useHeadMatch && useHeadMatch[1]) {
|
|
85
|
+
const headBody = useHeadMatch[1];
|
|
86
|
+
const callLine = lineNumberAtOffset(fileContent, scriptOffset + useHeadMatch.index);
|
|
87
|
+
|
|
88
|
+
const titleProp = /title\s*:\s*["']([^"']+)["']/i.exec(headBody);
|
|
89
|
+
if (titleProp && !title) {
|
|
90
|
+
title = { value: titleProp[1] ?? '', line: callLine };
|
|
91
|
+
} else if (/title\s*:\s*[`()]/i.test(headBody) && !title) {
|
|
92
|
+
title = { value: '[Valeur dynamique]', line: callLine };
|
|
93
|
+
isDynamic = true;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 2. Scan template block for components: <Title>, <Meta>, <NuxtLink>, <html lang>
|
|
99
|
+
const templateMatch = /<template\b[^>]*>([\s\S]*?)<\/template>/i.exec(fileContent);
|
|
100
|
+
const templateContent = templateMatch && templateMatch[1] ? templateMatch[1] : fileContent;
|
|
101
|
+
const templateOffset = templateMatch ? templateMatch.index : 0;
|
|
102
|
+
|
|
103
|
+
// <html lang="...">
|
|
104
|
+
const htmlTagMatch = /<html\b([^>]*?)>/i.exec(templateContent);
|
|
105
|
+
if (htmlTagMatch) {
|
|
106
|
+
const langVal = getAttr(htmlTagMatch[1] ?? '', 'lang');
|
|
107
|
+
if (langVal) {
|
|
108
|
+
htmlLang = {
|
|
109
|
+
value: langVal.trim(),
|
|
110
|
+
line: lineNumberAtOffset(fileContent, templateOffset + htmlTagMatch.index),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// <Title>...</Title> or <title>...</title>
|
|
116
|
+
const titleTagMatch = /<[Tt]itle[^>]*>([\s\S]*?)<\/[Tt]itle>/i.exec(templateContent);
|
|
117
|
+
if (titleTagMatch && !title) {
|
|
118
|
+
const rawVal = (titleTagMatch[1] ?? '').trim();
|
|
119
|
+
const line = lineNumberAtOffset(fileContent, templateOffset + titleTagMatch.index);
|
|
120
|
+
if (/\{\{[^}]+\}\}/.test(rawVal)) {
|
|
121
|
+
title = { value: '[Valeur dynamique]', line };
|
|
122
|
+
isDynamic = true;
|
|
123
|
+
} else {
|
|
124
|
+
title = { value: rawVal.replace(/\s+/g, ' '), line };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// <Meta name="..." content="..." /> or <meta ...>
|
|
129
|
+
const metaRegex = /<[Mm]eta\b([^>]*?)\/?>/gi;
|
|
130
|
+
let mMatch: RegExpExecArray | null;
|
|
131
|
+
while ((mMatch = metaRegex.exec(templateContent)) !== null) {
|
|
132
|
+
const attrs = mMatch[1] ?? '';
|
|
133
|
+
const line = lineNumberAtOffset(fileContent, templateOffset + mMatch.index);
|
|
134
|
+
const nameVal = getAttr(attrs, 'name')?.toLowerCase();
|
|
135
|
+
const contentVal = getAttr(attrs, 'content');
|
|
136
|
+
if (nameVal === 'description' && contentVal !== null && !description) {
|
|
137
|
+
description = { value: contentVal, line };
|
|
138
|
+
} else if (nameVal === 'robots' && contentVal !== null && !robots) {
|
|
139
|
+
robots = { value: contentVal, line };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Headings: <h1> to <h6>
|
|
144
|
+
const headingRegex = /<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/gi;
|
|
145
|
+
let hMatch: RegExpExecArray | null;
|
|
146
|
+
while ((hMatch = headingRegex.exec(templateContent)) !== null) {
|
|
147
|
+
const level = Number(hMatch[1]) as 1 | 2 | 3 | 4 | 5 | 6;
|
|
148
|
+
const text = (hMatch[2] ?? '').replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
|
|
149
|
+
headings.push({
|
|
150
|
+
level,
|
|
151
|
+
text,
|
|
152
|
+
line: lineNumberAtOffset(fileContent, templateOffset + hMatch.index),
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Links: <NuxtLink to="..."> or <a href="...">
|
|
157
|
+
const nuxtLinkRegex = /<NuxtLink\b([^>]*?)>([\s\S]*?)<\/NuxtLink>/gi;
|
|
158
|
+
let nlMatch: RegExpExecArray | null;
|
|
159
|
+
while ((nlMatch = nuxtLinkRegex.exec(templateContent)) !== null) {
|
|
160
|
+
const attrs = nlMatch[1] ?? '';
|
|
161
|
+
const to = getAttr(attrs, 'to') || getAttr(attrs, ':to') || getAttr(attrs, 'href');
|
|
162
|
+
if (to) {
|
|
163
|
+
const anchorText = (nlMatch[2] ?? '').replace(/<[^>]+>/g, '').trim();
|
|
164
|
+
links.push({
|
|
165
|
+
toUrl: to,
|
|
166
|
+
anchorText,
|
|
167
|
+
kind: to.startsWith('http://') || to.startsWith('https://') ? 'external' : 'internal',
|
|
168
|
+
rel: getAttr(attrs, 'rel'),
|
|
169
|
+
line: lineNumberAtOffset(fileContent, templateOffset + nlMatch.index),
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const aRegex = /<a\b([^>]*?)>([\s\S]*?)<\/a>/gi;
|
|
175
|
+
let aMatch: RegExpExecArray | null;
|
|
176
|
+
while ((aMatch = aRegex.exec(templateContent)) !== null) {
|
|
177
|
+
const attrs = aMatch[1] ?? '';
|
|
178
|
+
const href = getAttr(attrs, 'href');
|
|
179
|
+
if (href) {
|
|
180
|
+
const anchorText = (aMatch[2] ?? '').replace(/<[^>]+>/g, '').trim();
|
|
181
|
+
links.push({
|
|
182
|
+
toUrl: href,
|
|
183
|
+
anchorText,
|
|
184
|
+
kind: href.startsWith('http://') || href.startsWith('https://') ? 'external' : 'internal',
|
|
185
|
+
rel: getAttr(attrs, 'rel'),
|
|
186
|
+
line: lineNumberAtOffset(fileContent, templateOffset + aMatch.index),
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
route,
|
|
193
|
+
title,
|
|
194
|
+
description,
|
|
195
|
+
canonical,
|
|
196
|
+
robots,
|
|
197
|
+
htmlLang,
|
|
198
|
+
headings,
|
|
199
|
+
openGraph,
|
|
200
|
+
hreflang,
|
|
201
|
+
images,
|
|
202
|
+
links,
|
|
203
|
+
jsonLdItems,
|
|
204
|
+
isDynamic,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { DiscoveredRoute, ExtractedRouteData } from '@seo-partner/core';
|
|
2
|
+
import type { AdapterCapabilities, FrameworkAdapter } from './contract.js';
|
|
3
|
+
import { isPrivateRoutePattern } from './nextjs-adapter.js';
|
|
4
|
+
import {
|
|
5
|
+
detectPhpFlavor,
|
|
6
|
+
discoverNativePhpRoutes,
|
|
7
|
+
parseLaravelRoutes,
|
|
8
|
+
parseSymfonyRoutes,
|
|
9
|
+
} from './parsers/php-route-parser.js';
|
|
10
|
+
import { parsePhpOrTemplateFile } from './parsers/php-template-parser.js';
|
|
11
|
+
|
|
12
|
+
export class PhpAdapter implements FrameworkAdapter {
|
|
13
|
+
public readonly id = 'php';
|
|
14
|
+
public readonly name = 'PHP Ecosystem (Laravel / Blade, Symfony / Twig, Native PHP / Laragon)';
|
|
15
|
+
public readonly version = '1.0' as const;
|
|
16
|
+
public readonly capabilities: AdapterCapabilities = {
|
|
17
|
+
routes: true,
|
|
18
|
+
metadata: true,
|
|
19
|
+
headings: true,
|
|
20
|
+
internalLinks: true,
|
|
21
|
+
jsonLd: true,
|
|
22
|
+
images: true,
|
|
23
|
+
robotsAndSitemap: true,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
public async detect(
|
|
27
|
+
_projectRoot: string,
|
|
28
|
+
filePaths: ReadonlyArray<string>,
|
|
29
|
+
contentsByPath: ReadonlyMap<string, string>,
|
|
30
|
+
): Promise<boolean> {
|
|
31
|
+
return detectPhpFlavor(filePaths, contentsByPath) !== null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
public async discoverRoutes(
|
|
35
|
+
_projectRoot: string,
|
|
36
|
+
filePaths: ReadonlyArray<string>,
|
|
37
|
+
contentsByPath: ReadonlyMap<string, string>,
|
|
38
|
+
sitemapPaths: ReadonlySet<string>,
|
|
39
|
+
): Promise<ReadonlyArray<DiscoveredRoute>> {
|
|
40
|
+
const flavor = detectPhpFlavor(filePaths, contentsByPath);
|
|
41
|
+
if (!flavor) return [];
|
|
42
|
+
|
|
43
|
+
const entries =
|
|
44
|
+
flavor === 'laravel'
|
|
45
|
+
? parseLaravelRoutes(filePaths, contentsByPath)
|
|
46
|
+
: flavor === 'symfony'
|
|
47
|
+
? parseSymfonyRoutes(filePaths, contentsByPath)
|
|
48
|
+
: discoverNativePhpRoutes(filePaths);
|
|
49
|
+
|
|
50
|
+
return entries.map((entry) => ({
|
|
51
|
+
pattern: entry.pattern,
|
|
52
|
+
filePath: entry.filePath,
|
|
53
|
+
routerKind: entry.flavor,
|
|
54
|
+
renderingMode: 'ssr-ssg',
|
|
55
|
+
isPrivate: isPrivateRoutePattern(entry.pattern),
|
|
56
|
+
inSitemap: sitemapPaths.has(entry.pattern),
|
|
57
|
+
}));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
public async extractRoute(
|
|
61
|
+
projectRoot: string,
|
|
62
|
+
route: DiscoveredRoute,
|
|
63
|
+
fileContent: string,
|
|
64
|
+
): Promise<ExtractedRouteData> {
|
|
65
|
+
return parsePhpOrTemplateFile(projectRoot, route, fileContent);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|