@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,46 @@
|
|
|
1
|
+
export interface PhpNativeMetaFields {
|
|
2
|
+
title?: string;
|
|
3
|
+
description?: string;
|
|
4
|
+
canonical?: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function patchPhpNativeTemplate(
|
|
8
|
+
sourceCode: string,
|
|
9
|
+
meta: PhpNativeMetaFields,
|
|
10
|
+
): string {
|
|
11
|
+
let result = sourceCode;
|
|
12
|
+
|
|
13
|
+
const vars: string[] = [];
|
|
14
|
+
if (meta.title && !/\$pageTitle\s*=/i.test(result)) {
|
|
15
|
+
vars.push(`$pageTitle = '${meta.title.replace(/'/g, "\\'")}';`);
|
|
16
|
+
}
|
|
17
|
+
if (meta.description && !/\$metaDescription\s*=/i.test(result)) {
|
|
18
|
+
vars.push(`$metaDescription = '${meta.description.replace(/'/g, "\\'")}';`);
|
|
19
|
+
}
|
|
20
|
+
if (meta.canonical && !/\$canonicalUrl\s*=/i.test(result)) {
|
|
21
|
+
vars.push(`$canonicalUrl = '${meta.canonical.replace(/'/g, "\\'")}';`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (vars.length === 0) {
|
|
25
|
+
return result;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const varsBlock = vars.join('\n') + '\n';
|
|
29
|
+
|
|
30
|
+
// If file starts with <?php, inject before any header include
|
|
31
|
+
if (/<\?php/i.test(result)) {
|
|
32
|
+
if (/(include|require|include_once|require_once)\s*['"][^'"]*header[^'"]*['"]/i.test(result)) {
|
|
33
|
+
result = result.replace(
|
|
34
|
+
/((?:include|require|include_once|require_once)\s*['"][^'"]*header[^'"]*['"])/i,
|
|
35
|
+
`${varsBlock}$1`,
|
|
36
|
+
);
|
|
37
|
+
} else {
|
|
38
|
+
result = result.replace(/(<\?php\s*\n)/i, `$1${varsBlock}`);
|
|
39
|
+
}
|
|
40
|
+
} else {
|
|
41
|
+
result = `<?php\n${varsBlock}?>\n` + result;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
46
|
+
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { DiscoveredRoute, ExtractedRouteData } from '@seo-partner/core';
|
|
2
|
+
import type { AdapterCapabilities, AdapterVersion, FrameworkAdapter } from './contract.js';
|
|
3
|
+
import { parseRemixRoute } from './parsers/remix-parser.js';
|
|
4
|
+
import { normalizeRoutePattern } from './parsers/route-normalizer.js';
|
|
5
|
+
import { LayoutResolver } from './parsers/layout-resolver.js';
|
|
6
|
+
|
|
7
|
+
export class RemixAdapter implements FrameworkAdapter {
|
|
8
|
+
public readonly id = 'remix';
|
|
9
|
+
public readonly name = 'Remix / React Router v7';
|
|
10
|
+
public readonly version: AdapterVersion = '1.0';
|
|
11
|
+
public readonly capabilities: AdapterCapabilities = {
|
|
12
|
+
routes: true,
|
|
13
|
+
metadata: true,
|
|
14
|
+
headings: true,
|
|
15
|
+
internalLinks: true,
|
|
16
|
+
jsonLd: true,
|
|
17
|
+
images: true,
|
|
18
|
+
robotsAndSitemap: true,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
private readonly layoutResolver = new LayoutResolver();
|
|
22
|
+
|
|
23
|
+
public async detect(
|
|
24
|
+
projectRoot: string,
|
|
25
|
+
filePaths: ReadonlyArray<string>,
|
|
26
|
+
contentsByPath: ReadonlyMap<string, string>,
|
|
27
|
+
): Promise<boolean> {
|
|
28
|
+
for (const filePath of filePaths) {
|
|
29
|
+
const normalized = filePath.replace(/\\/g, '/');
|
|
30
|
+
if (
|
|
31
|
+
normalized === 'remix.config.js' ||
|
|
32
|
+
normalized === 'remix.config.ts' ||
|
|
33
|
+
normalized === 'remix.config.mjs' ||
|
|
34
|
+
normalized === 'react-router.config.ts' ||
|
|
35
|
+
normalized === 'react-router.config.js'
|
|
36
|
+
) {
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
if (normalized === 'package.json') {
|
|
40
|
+
const content = contentsByPath.get(filePath) ?? '';
|
|
41
|
+
if (
|
|
42
|
+
content.includes('"@remix-run/') ||
|
|
43
|
+
content.includes('"react-router"')
|
|
44
|
+
) {
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
public async discoverRoutes(
|
|
53
|
+
projectRoot: string,
|
|
54
|
+
filePaths: ReadonlyArray<string>,
|
|
55
|
+
contentsByPath: ReadonlyMap<string, string>,
|
|
56
|
+
sitemapPaths: ReadonlySet<string>,
|
|
57
|
+
): Promise<ReadonlyArray<DiscoveredRoute>> {
|
|
58
|
+
const routes: DiscoveredRoute[] = [];
|
|
59
|
+
|
|
60
|
+
// Pre-cache root layout from app/root.tsx
|
|
61
|
+
for (const filePath of filePaths) {
|
|
62
|
+
const normalized = filePath.replace(/\\/g, '/');
|
|
63
|
+
if (normalized === 'app/root.tsx' || normalized === 'app/root.jsx') {
|
|
64
|
+
const content = contentsByPath.get(filePath) ?? '';
|
|
65
|
+
const parsed = parseRemixRoute(
|
|
66
|
+
{
|
|
67
|
+
pattern: '',
|
|
68
|
+
filePath,
|
|
69
|
+
routerKind: 'remix',
|
|
70
|
+
renderingMode: 'ssr-ssg',
|
|
71
|
+
isPrivate: false,
|
|
72
|
+
inSitemap: false,
|
|
73
|
+
},
|
|
74
|
+
content,
|
|
75
|
+
);
|
|
76
|
+
this.layoutResolver.registerLayout({
|
|
77
|
+
filePath,
|
|
78
|
+
htmlLang: parsed.htmlLang,
|
|
79
|
+
title: parsed.title,
|
|
80
|
+
description: parsed.description,
|
|
81
|
+
canonical: parsed.canonical,
|
|
82
|
+
robots: parsed.robots,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
for (const filePath of filePaths) {
|
|
88
|
+
const normalized = filePath.replace(/\\/g, '/');
|
|
89
|
+
if (!normalized.startsWith('app/routes/')) continue;
|
|
90
|
+
if (!/\.(tsx|jsx|ts|js)$/i.test(normalized)) continue;
|
|
91
|
+
|
|
92
|
+
const relativeInsideRoutes = normalized.slice('app/routes/'.length);
|
|
93
|
+
const pattern = normalizeRoutePattern(relativeInsideRoutes, 'remix');
|
|
94
|
+
const inSitemap = sitemapPaths.has(pattern);
|
|
95
|
+
|
|
96
|
+
routes.push({
|
|
97
|
+
pattern,
|
|
98
|
+
filePath,
|
|
99
|
+
routerKind: 'remix',
|
|
100
|
+
renderingMode: 'ssr-ssg',
|
|
101
|
+
isPrivate: false,
|
|
102
|
+
inSitemap,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return routes;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
public async extractRoute(
|
|
110
|
+
projectRoot: string,
|
|
111
|
+
route: DiscoveredRoute,
|
|
112
|
+
fileContent: string,
|
|
113
|
+
): Promise<ExtractedRouteData> {
|
|
114
|
+
let extracted = parseRemixRoute(route, fileContent);
|
|
115
|
+
|
|
116
|
+
// Apply root layout inheritance
|
|
117
|
+
const layoutCandidates = ['app/root.tsx', 'app/root.jsx'];
|
|
118
|
+
extracted = this.layoutResolver.applyInheritance(extracted, layoutCandidates);
|
|
119
|
+
|
|
120
|
+
return extracted;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export interface SitemapRouteEntry {
|
|
2
|
+
pattern: string;
|
|
3
|
+
isPrivate: boolean;
|
|
4
|
+
lastmod?: string;
|
|
5
|
+
changefreq?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
|
|
6
|
+
priority?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function buildSitemapXml(
|
|
10
|
+
routes: ReadonlyArray<SitemapRouteEntry>,
|
|
11
|
+
siteUrl: string,
|
|
12
|
+
): string {
|
|
13
|
+
const cleanBase = siteUrl.replace(/\/+$/, '');
|
|
14
|
+
const publicRoutes = routes.filter((r) => !r.isPrivate);
|
|
15
|
+
|
|
16
|
+
const urlEntries = publicRoutes.map((r) => {
|
|
17
|
+
const cleanPattern = r.pattern.startsWith('/') ? r.pattern : `/${r.pattern}`;
|
|
18
|
+
const loc = `${cleanBase}${cleanPattern === '/' ? '' : cleanPattern}`;
|
|
19
|
+
const lastmodTag = r.lastmod ? `\n <lastmod>${r.lastmod}</lastmod>` : '';
|
|
20
|
+
const changefreqTag = r.changefreq ? `\n <changefreq>${r.changefreq}</changefreq>` : '';
|
|
21
|
+
const priorityTag = typeof r.priority === 'number' ? `\n <priority>${r.priority.toFixed(1)}</priority>` : '';
|
|
22
|
+
|
|
23
|
+
return ` <url>\n <loc>${loc}</loc>${lastmodTag}${changefreqTag}${priorityTag}\n </url>`;
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
return [
|
|
27
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
28
|
+
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
|
29
|
+
...urlEntries,
|
|
30
|
+
'</urlset>',
|
|
31
|
+
].join('\n');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function buildNextJsAppSitemap(
|
|
35
|
+
routes: ReadonlyArray<SitemapRouteEntry>,
|
|
36
|
+
siteUrl: string,
|
|
37
|
+
): string {
|
|
38
|
+
const cleanBase = siteUrl.replace(/\/+$/, '');
|
|
39
|
+
const publicRoutes = routes.filter((r) => !r.isPrivate);
|
|
40
|
+
|
|
41
|
+
const items = publicRoutes.map((r) => {
|
|
42
|
+
const cleanPattern = r.pattern.startsWith('/') ? r.pattern : `/${r.pattern}`;
|
|
43
|
+
const loc = `${cleanBase}${cleanPattern === '/' ? '' : cleanPattern}`;
|
|
44
|
+
return ` {\n url: '${loc}',\n lastModified: new Date(),\n changeFrequency: 'weekly',\n priority: ${cleanPattern === '/' ? '1.0' : '0.8'},\n },`;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
return [
|
|
48
|
+
"import type { MetadataRoute } from 'next';",
|
|
49
|
+
'',
|
|
50
|
+
'export default function sitemap(): MetadataRoute.Sitemap {',
|
|
51
|
+
' return [',
|
|
52
|
+
...items,
|
|
53
|
+
' ];',
|
|
54
|
+
'}',
|
|
55
|
+
].join('\n');
|
|
56
|
+
}
|
|
57
|
+
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export interface SeoFileInventory {
|
|
5
|
+
robots: {
|
|
6
|
+
appRobotsTs?: string;
|
|
7
|
+
publicRobotsTxt?: string;
|
|
8
|
+
hasConflict: boolean;
|
|
9
|
+
};
|
|
10
|
+
sitemap: {
|
|
11
|
+
appSitemapTs?: string;
|
|
12
|
+
publicSitemapXml?: string;
|
|
13
|
+
hasConflict: boolean;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function detectSeoFileInventory(projectRoot: string): SeoFileInventory {
|
|
18
|
+
const checkExists = (rel: string) => {
|
|
19
|
+
const full = path.join(projectRoot, rel);
|
|
20
|
+
return fs.existsSync(full) ? rel : undefined;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const appRobotsTs =
|
|
24
|
+
checkExists('app/robots.ts') ||
|
|
25
|
+
checkExists('app/robots.js') ||
|
|
26
|
+
checkExists('src/app/robots.ts') ||
|
|
27
|
+
checkExists('src/app/robots.js');
|
|
28
|
+
|
|
29
|
+
const publicRobotsTxt =
|
|
30
|
+
checkExists('public/robots.txt') ||
|
|
31
|
+
checkExists('robots.txt');
|
|
32
|
+
|
|
33
|
+
const appSitemapTs =
|
|
34
|
+
checkExists('app/sitemap.ts') ||
|
|
35
|
+
checkExists('app/sitemap.js') ||
|
|
36
|
+
checkExists('src/app/sitemap.ts') ||
|
|
37
|
+
checkExists('src/app/sitemap.js');
|
|
38
|
+
|
|
39
|
+
const publicSitemapXml =
|
|
40
|
+
checkExists('public/sitemap.xml') ||
|
|
41
|
+
checkExists('sitemap.xml');
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
robots: {
|
|
45
|
+
appRobotsTs,
|
|
46
|
+
publicRobotsTxt,
|
|
47
|
+
hasConflict: Boolean(appRobotsTs && publicRobotsTxt),
|
|
48
|
+
},
|
|
49
|
+
sitemap: {
|
|
50
|
+
appSitemapTs,
|
|
51
|
+
publicSitemapXml,
|
|
52
|
+
hasConflict: Boolean(appSitemapTs && publicSitemapXml),
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
@@ -0,0 +1,62 @@
|
|
|
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 { parseStaticHtmlOrMarkdown } from './parsers/html-parser.js';
|
|
5
|
+
|
|
6
|
+
export class StaticAdapter implements FrameworkAdapter {
|
|
7
|
+
public readonly id = 'static-html';
|
|
8
|
+
public readonly name = 'Static HTML & Markdown';
|
|
9
|
+
public readonly version = '1.0' as const;
|
|
10
|
+
public readonly capabilities: AdapterCapabilities = {
|
|
11
|
+
routes: true,
|
|
12
|
+
metadata: true,
|
|
13
|
+
headings: true,
|
|
14
|
+
internalLinks: true,
|
|
15
|
+
jsonLd: true,
|
|
16
|
+
images: true,
|
|
17
|
+
robotsAndSitemap: true,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
public async detect(
|
|
21
|
+
_projectRoot: string,
|
|
22
|
+
filePaths: ReadonlyArray<string>,
|
|
23
|
+
): Promise<boolean> {
|
|
24
|
+
return filePaths.some((p) => p.endsWith('.html'));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
public async discoverRoutes(
|
|
28
|
+
_projectRoot: string,
|
|
29
|
+
filePaths: ReadonlyArray<string>,
|
|
30
|
+
_contentsByPath: ReadonlyMap<string, string>,
|
|
31
|
+
sitemapPaths: ReadonlySet<string>,
|
|
32
|
+
): Promise<ReadonlyArray<DiscoveredRoute>> {
|
|
33
|
+
const routes: DiscoveredRoute[] = [];
|
|
34
|
+
for (const filePath of filePaths) {
|
|
35
|
+
if (filePath.endsWith('.html')) {
|
|
36
|
+
const clean = filePath
|
|
37
|
+
.replace(/^public\//, '')
|
|
38
|
+
.replace(/index\.html$/, '')
|
|
39
|
+
.replace(/\.html$/, '');
|
|
40
|
+
const pattern = clean.startsWith('/') ? clean : `/${clean}`;
|
|
41
|
+
routes.push({
|
|
42
|
+
pattern: pattern === '' ? '/' : pattern,
|
|
43
|
+
filePath,
|
|
44
|
+
routerKind: 'static-html',
|
|
45
|
+
renderingMode: 'static',
|
|
46
|
+
isPrivate: isPrivateRoutePattern(pattern),
|
|
47
|
+
inSitemap: sitemapPaths.has(pattern),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return routes;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
public async extractRoute(
|
|
55
|
+
_projectRoot: string,
|
|
56
|
+
route: DiscoveredRoute,
|
|
57
|
+
fileContent: string,
|
|
58
|
+
): Promise<ExtractedRouteData> {
|
|
59
|
+
return parseStaticHtmlOrMarkdown(route, fileContent);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import type { DiscoveredRoute, ExtractedRouteData } from '@seo-partner/core';
|
|
2
|
+
import type { AdapterCapabilities, AdapterVersion, FrameworkAdapter } from './contract.js';
|
|
3
|
+
import { parseSvelteComponent } from './parsers/svelte-parser.js';
|
|
4
|
+
import { normalizeRoutePattern } from './parsers/route-normalizer.js';
|
|
5
|
+
import { LayoutResolver } from './parsers/layout-resolver.js';
|
|
6
|
+
|
|
7
|
+
export class SvelteKitAdapter implements FrameworkAdapter {
|
|
8
|
+
public readonly id = 'sveltekit';
|
|
9
|
+
public readonly name = 'SvelteKit';
|
|
10
|
+
public readonly version: AdapterVersion = '1.0';
|
|
11
|
+
public readonly capabilities: AdapterCapabilities = {
|
|
12
|
+
routes: true,
|
|
13
|
+
metadata: true,
|
|
14
|
+
headings: true,
|
|
15
|
+
internalLinks: true,
|
|
16
|
+
jsonLd: true,
|
|
17
|
+
images: true,
|
|
18
|
+
robotsAndSitemap: true,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
private readonly layoutResolver = new LayoutResolver();
|
|
22
|
+
|
|
23
|
+
public async detect(
|
|
24
|
+
projectRoot: string,
|
|
25
|
+
filePaths: ReadonlyArray<string>,
|
|
26
|
+
contentsByPath: ReadonlyMap<string, string>,
|
|
27
|
+
): Promise<boolean> {
|
|
28
|
+
for (const filePath of filePaths) {
|
|
29
|
+
const normalized = filePath.replace(/\\/g, '/');
|
|
30
|
+
if (
|
|
31
|
+
normalized === 'svelte.config.js' ||
|
|
32
|
+
normalized === 'svelte.config.ts' ||
|
|
33
|
+
normalized === 'svelte.config.mjs' ||
|
|
34
|
+
normalized === 'svelte.config.cjs'
|
|
35
|
+
) {
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
if (normalized === 'package.json') {
|
|
39
|
+
const content = contentsByPath.get(filePath) ?? '';
|
|
40
|
+
if (content.includes('"@sveltejs/kit"')) {
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
public async discoverRoutes(
|
|
49
|
+
projectRoot: string,
|
|
50
|
+
filePaths: ReadonlyArray<string>,
|
|
51
|
+
contentsByPath: ReadonlyMap<string, string>,
|
|
52
|
+
sitemapPaths: ReadonlySet<string>,
|
|
53
|
+
): Promise<ReadonlyArray<DiscoveredRoute>> {
|
|
54
|
+
const routes: DiscoveredRoute[] = [];
|
|
55
|
+
|
|
56
|
+
// Pre-cache layouts from src/routes/**/+layout.svelte
|
|
57
|
+
for (const filePath of filePaths) {
|
|
58
|
+
const normalized = filePath.replace(/\\/g, '/');
|
|
59
|
+
if (normalized.startsWith('src/routes/') && normalized.endsWith('+layout.svelte')) {
|
|
60
|
+
const content = contentsByPath.get(filePath) ?? '';
|
|
61
|
+
const parsed = parseSvelteComponent(
|
|
62
|
+
{
|
|
63
|
+
pattern: '',
|
|
64
|
+
filePath,
|
|
65
|
+
routerKind: 'sveltekit',
|
|
66
|
+
renderingMode: 'ssr-ssg',
|
|
67
|
+
isPrivate: false,
|
|
68
|
+
inSitemap: false,
|
|
69
|
+
},
|
|
70
|
+
content,
|
|
71
|
+
);
|
|
72
|
+
this.layoutResolver.registerLayout({
|
|
73
|
+
filePath,
|
|
74
|
+
htmlLang: parsed.htmlLang,
|
|
75
|
+
title: parsed.title,
|
|
76
|
+
description: parsed.description,
|
|
77
|
+
canonical: parsed.canonical,
|
|
78
|
+
robots: parsed.robots,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
for (const filePath of filePaths) {
|
|
84
|
+
const normalized = filePath.replace(/\\/g, '/');
|
|
85
|
+
if (!normalized.startsWith('src/routes/')) continue;
|
|
86
|
+
if (!normalized.endsWith('+page.svelte')) continue;
|
|
87
|
+
|
|
88
|
+
const relativeInsideRoutes = normalized.slice('src/routes/'.length);
|
|
89
|
+
const pattern = normalizeRoutePattern(relativeInsideRoutes, 'sveltekit');
|
|
90
|
+
const inSitemap = sitemapPaths.has(pattern);
|
|
91
|
+
|
|
92
|
+
routes.push({
|
|
93
|
+
pattern,
|
|
94
|
+
filePath,
|
|
95
|
+
routerKind: 'sveltekit',
|
|
96
|
+
renderingMode: 'ssr-ssg',
|
|
97
|
+
isPrivate: false,
|
|
98
|
+
inSitemap,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return routes;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
public async extractRoute(
|
|
106
|
+
projectRoot: string,
|
|
107
|
+
route: DiscoveredRoute,
|
|
108
|
+
fileContent: string,
|
|
109
|
+
): Promise<ExtractedRouteData> {
|
|
110
|
+
let extracted = parseSvelteComponent(route, fileContent);
|
|
111
|
+
|
|
112
|
+
// Apply hierarchical layout inheritance from leaf up to root
|
|
113
|
+
const parentLayouts = this.layoutResolver.findSvelteKitLayoutPaths(route.filePath);
|
|
114
|
+
extracted = this.layoutResolver.applyInheritance(extracted, parentLayouts);
|
|
115
|
+
|
|
116
|
+
return extracted;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
export interface SvelteKitMetaFields {
|
|
122
|
+
title?: string;
|
|
123
|
+
description?: string;
|
|
124
|
+
canonical?: string;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function patchSvelteKitTemplate(
|
|
128
|
+
sourceCode: string,
|
|
129
|
+
meta: SvelteKitMetaFields,
|
|
130
|
+
): string {
|
|
131
|
+
let result = sourceCode;
|
|
132
|
+
|
|
133
|
+
if (/<svelte:head>/i.test(result)) {
|
|
134
|
+
if (meta.title && !/<title>/i.test(result)) {
|
|
135
|
+
result = result.replace(/(<svelte:head>\s*\n)/i, `$1 <title>${meta.title}</title>\n`);
|
|
136
|
+
}
|
|
137
|
+
if (meta.description && !/<meta\s+name=["']description["']/i.test(result)) {
|
|
138
|
+
result = result.replace(
|
|
139
|
+
/(<svelte:head>\s*\n)/i,
|
|
140
|
+
`$1 <meta name="description" content="${meta.description.replace(/"/g, '"')}" />\n`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
} else {
|
|
144
|
+
const headLines: string[] = ['<svelte:head>'];
|
|
145
|
+
if (meta.title) {
|
|
146
|
+
headLines.push(` <title>${meta.title}</title>`);
|
|
147
|
+
}
|
|
148
|
+
if (meta.description) {
|
|
149
|
+
headLines.push(` <meta name="description" content="${meta.description.replace(/"/g, '"')}" />`);
|
|
150
|
+
}
|
|
151
|
+
if (meta.canonical) {
|
|
152
|
+
headLines.push(` <link rel="canonical" href="${meta.canonical}" />`);
|
|
153
|
+
}
|
|
154
|
+
headLines.push('</svelte:head>\n\n');
|
|
155
|
+
|
|
156
|
+
result = headLines.join('\n') + result;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return result;
|
|
160
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface SymfonyMetaFields {
|
|
2
|
+
title?: string;
|
|
3
|
+
description?: string;
|
|
4
|
+
canonical?: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function patchSymfonyTwigTemplate(
|
|
8
|
+
sourceCode: string,
|
|
9
|
+
meta: SymfonyMetaFields,
|
|
10
|
+
): string {
|
|
11
|
+
let result = sourceCode;
|
|
12
|
+
|
|
13
|
+
if (meta.title && !/{%\s*block\s+title\s*%}/i.test(result)) {
|
|
14
|
+
const titleBlock = `{% block title %}${meta.title}{% endblock %}\n`;
|
|
15
|
+
if (/{%\s*extends[^%]+%}/i.test(result)) {
|
|
16
|
+
result = result.replace(/({%\s*extends[^%]+%}\s*\n)/i, `$1${titleBlock}`);
|
|
17
|
+
} else {
|
|
18
|
+
result = titleBlock + result;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (meta.description && !/<meta\s+name=["']description["']/i.test(result)) {
|
|
23
|
+
const descTag = `<meta name="description" content="${meta.description.replace(/"/g, '"')}">\n`;
|
|
24
|
+
if (/{%\s*block\s+title\s*%}[\s\S]*?{%\s*endblock\s*%}/i.test(result)) {
|
|
25
|
+
result = result.replace(/({%\s*block\s+title\s*%}[\s\S]*?{%\s*endblock\s*%}\s*\n)/i, `$1${descTag}`);
|
|
26
|
+
} else {
|
|
27
|
+
result = descTag + result;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
|
package/tsconfig.json
ADDED
package/README.md
DELETED