@lynxflow/seo-engine 1.5.9 → 1.6.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/dist/i18n-detector.d.ts +24 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +92 -0
- package/dist/index.mjs +92 -0
- package/dist/matrix-engine.d.ts +27 -0
- package/package.json +1 -1
- package/src/engine.test.ts +31 -0
- package/src/i18n-detector.ts +70 -0
- package/src/index.ts +1 -0
- package/src/matrix-engine.ts +85 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 🌐 Dynamic i18n & Locale Auto-Detector
|
|
3
|
+
* Automatically detects and synchronizes website locales without requiring hardcoded lists.
|
|
4
|
+
* Compatible with next-intl, next-i18next, i18next, Paraglide, and Astro i18n.
|
|
5
|
+
*/
|
|
6
|
+
export declare const SUPPORTED_CANONICAL_LOCALES: readonly ["en", "fr", "es", "de", "it", "pt", "nl", "ru", "sv", "pl", "ja", "zh", "ar"];
|
|
7
|
+
export type SupportedLocale = (typeof SUPPORTED_CANONICAL_LOCALES)[number];
|
|
8
|
+
export declare class I18nDetector {
|
|
9
|
+
/**
|
|
10
|
+
* Resolves a clean 2-letter ISO language code from any locale string (e.g. "fr-FR" -> "fr").
|
|
11
|
+
*/
|
|
12
|
+
static normalizeLocale(localeInput?: string): string;
|
|
13
|
+
/**
|
|
14
|
+
* Auto-detects and extracts active locales from common environment variables or array inputs.
|
|
15
|
+
*/
|
|
16
|
+
static resolveActiveLocales(explicitLocales?: string[] | string): string[];
|
|
17
|
+
/**
|
|
18
|
+
* Matches whether a given path segment is a language prefix (e.g. "/fr/autopost-facebook/paris").
|
|
19
|
+
*/
|
|
20
|
+
static extractLocaleFromPath(pathname: string, activeLocales: string[]): {
|
|
21
|
+
locale: string;
|
|
22
|
+
remainingPath: string;
|
|
23
|
+
};
|
|
24
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -14,6 +14,7 @@ export * from "./slug-engine";
|
|
|
14
14
|
export * from "./legal-disclaimers";
|
|
15
15
|
export * from "./matrix-engine";
|
|
16
16
|
export * from "./built-in-locations";
|
|
17
|
+
export * from "./i18n-detector";
|
|
17
18
|
export * from "./brand-icons";
|
|
18
19
|
export * from "./urlytics-engine";
|
|
19
20
|
export * from "./keyword-permutator";
|
package/dist/index.js
CHANGED
|
@@ -61,6 +61,7 @@ __export(exports_src, {
|
|
|
61
61
|
SerpClient: () => SerpClient,
|
|
62
62
|
SeoOpportunitiesDecayDetector: () => SeoOpportunitiesDecayDetector,
|
|
63
63
|
SchemaGraphBuilder: () => SchemaGraphBuilder,
|
|
64
|
+
SUPPORTED_CANONICAL_LOCALES: () => SUPPORTED_CANONICAL_LOCALES,
|
|
64
65
|
SCHEMA_LOCAL_BUSINESS_MAP: () => SCHEMA_LOCAL_BUSINESS_MAP,
|
|
65
66
|
RssSyndicationFeedGenerator: () => RssSyndicationFeedGenerator,
|
|
66
67
|
RealReviewsSyncEngine: () => RealReviewsSyncEngine,
|
|
@@ -84,6 +85,7 @@ __export(exports_src, {
|
|
|
84
85
|
IsrCacheManager: () => IsrCacheManager,
|
|
85
86
|
InstantMatrixSearchEngine: () => InstantMatrixSearchEngine,
|
|
86
87
|
IndexNowClient: () => IndexNowClient,
|
|
88
|
+
I18nDetector: () => I18nDetector,
|
|
87
89
|
HyperswitchGateway: () => HyperswitchGateway,
|
|
88
90
|
GeoMeshLinkingEngine: () => GeoMeshLinkingEngine,
|
|
89
91
|
ExtendedSchemaGraphBuilder: () => ExtendedSchemaGraphBuilder,
|
|
@@ -3862,6 +3864,11 @@ class PseoMatrixEngine {
|
|
|
3862
3864
|
${urls}
|
|
3863
3865
|
</urlset>`;
|
|
3864
3866
|
}
|
|
3867
|
+
resolveServicePage(serviceSlug, citySlug, domain, data, options) {
|
|
3868
|
+
const cleanService = cleanSeoSlug(serviceSlug, { language: options.language });
|
|
3869
|
+
const cleanCity = cleanSeoSlug(citySlug, { language: options.language });
|
|
3870
|
+
return this.resolvePage([cleanService, cleanCity], domain, data, options);
|
|
3871
|
+
}
|
|
3865
3872
|
generateLlmsTxt(domain, data, options) {
|
|
3866
3873
|
const pages = this.generateAllMatrices(domain, data, options);
|
|
3867
3874
|
const lines = [
|
|
@@ -3876,6 +3883,91 @@ ${urls}
|
|
|
3876
3883
|
return lines.join(`
|
|
3877
3884
|
`);
|
|
3878
3885
|
}
|
|
3886
|
+
generateRobotsTxt(domain, options) {
|
|
3887
|
+
const cleanDomain = domain.replace(/\/+$/, "");
|
|
3888
|
+
const sitemap = options?.sitemapUrl || `${cleanDomain}/sitemap.xml`;
|
|
3889
|
+
const disallows = options?.disallowPaths || ["/api/", "/admin/", "/private/"];
|
|
3890
|
+
const lines = [
|
|
3891
|
+
"User-agent: *",
|
|
3892
|
+
"Allow: /",
|
|
3893
|
+
...disallows.map((d) => `Disallow: ${d}`),
|
|
3894
|
+
""
|
|
3895
|
+
];
|
|
3896
|
+
if (options?.allowAiBots !== false) {
|
|
3897
|
+
lines.push("# AI Search Crawlers Optimization (AEO/GEO)", "User-agent: GPTBot", "Allow: /", "", "User-agent: ClaudeBot", "Allow: /", "", "User-agent: PerplexityBot", "Allow: /", "", "User-agent: Applebot", "Allow: /", "");
|
|
3898
|
+
}
|
|
3899
|
+
lines.push(`Sitemap: ${sitemap}`);
|
|
3900
|
+
return lines.join(`
|
|
3901
|
+
`);
|
|
3902
|
+
}
|
|
3903
|
+
getTelemetryPayload(domain, data, options) {
|
|
3904
|
+
const pages = this.generateAllMatrices(domain, data, options);
|
|
3905
|
+
const indexedPages = pages.filter((p) => p.robots.includes("index"));
|
|
3906
|
+
const matrixBreakdown = {};
|
|
3907
|
+
for (const p of pages) {
|
|
3908
|
+
matrixBreakdown[p.matrixFamily] = (matrixBreakdown[p.matrixFamily] || 0) + 1;
|
|
3909
|
+
}
|
|
3910
|
+
return {
|
|
3911
|
+
domain,
|
|
3912
|
+
brandName: options.brandName,
|
|
3913
|
+
language: options.language || "en",
|
|
3914
|
+
languages: options.languages || [options.language || "en"],
|
|
3915
|
+
totalPages: pages.length,
|
|
3916
|
+
indexedPages: indexedPages.length,
|
|
3917
|
+
servicesCount: data.services?.length ?? 0,
|
|
3918
|
+
matrixBreakdown,
|
|
3919
|
+
timestamp: new Date().toISOString()
|
|
3920
|
+
};
|
|
3921
|
+
}
|
|
3922
|
+
}
|
|
3923
|
+
// src/i18n-detector.ts
|
|
3924
|
+
var SUPPORTED_CANONICAL_LOCALES = [
|
|
3925
|
+
"en",
|
|
3926
|
+
"fr",
|
|
3927
|
+
"es",
|
|
3928
|
+
"de",
|
|
3929
|
+
"it",
|
|
3930
|
+
"pt",
|
|
3931
|
+
"nl",
|
|
3932
|
+
"ru",
|
|
3933
|
+
"sv",
|
|
3934
|
+
"pl",
|
|
3935
|
+
"ja",
|
|
3936
|
+
"zh",
|
|
3937
|
+
"ar"
|
|
3938
|
+
];
|
|
3939
|
+
|
|
3940
|
+
class I18nDetector {
|
|
3941
|
+
static normalizeLocale(localeInput) {
|
|
3942
|
+
if (!localeInput || typeof localeInput !== "string")
|
|
3943
|
+
return "en";
|
|
3944
|
+
const clean = localeInput.toLowerCase().split(/[-_]/)[0].trim();
|
|
3945
|
+
return clean || "en";
|
|
3946
|
+
}
|
|
3947
|
+
static resolveActiveLocales(explicitLocales) {
|
|
3948
|
+
if (explicitLocales) {
|
|
3949
|
+
const list = Array.isArray(explicitLocales) ? explicitLocales : [explicitLocales];
|
|
3950
|
+
const normalized = list.map((l) => this.normalizeLocale(l)).filter(Boolean);
|
|
3951
|
+
return Array.from(new Set(normalized));
|
|
3952
|
+
}
|
|
3953
|
+
return ["en", "fr"];
|
|
3954
|
+
}
|
|
3955
|
+
static extractLocaleFromPath(pathname, activeLocales) {
|
|
3956
|
+
const cleanPath = pathname.startsWith("/") ? pathname : `/${pathname}`;
|
|
3957
|
+
const segments = cleanPath.split("/").filter(Boolean);
|
|
3958
|
+
if (segments.length > 0 && activeLocales.includes(segments[0].toLowerCase())) {
|
|
3959
|
+
const locale = segments[0].toLowerCase();
|
|
3960
|
+
const remainingSegments = segments.slice(1);
|
|
3961
|
+
return {
|
|
3962
|
+
locale,
|
|
3963
|
+
remainingPath: `/${remainingSegments.join("/")}`
|
|
3964
|
+
};
|
|
3965
|
+
}
|
|
3966
|
+
return {
|
|
3967
|
+
locale: activeLocales[0] || "en",
|
|
3968
|
+
remainingPath: cleanPath
|
|
3969
|
+
};
|
|
3970
|
+
}
|
|
3879
3971
|
}
|
|
3880
3972
|
// src/brand-icons.ts
|
|
3881
3973
|
var BRAND_ICONS = {
|
package/dist/index.mjs
CHANGED
|
@@ -3757,6 +3757,11 @@ class PseoMatrixEngine {
|
|
|
3757
3757
|
${urls}
|
|
3758
3758
|
</urlset>`;
|
|
3759
3759
|
}
|
|
3760
|
+
resolveServicePage(serviceSlug, citySlug, domain, data, options) {
|
|
3761
|
+
const cleanService = cleanSeoSlug(serviceSlug, { language: options.language });
|
|
3762
|
+
const cleanCity = cleanSeoSlug(citySlug, { language: options.language });
|
|
3763
|
+
return this.resolvePage([cleanService, cleanCity], domain, data, options);
|
|
3764
|
+
}
|
|
3760
3765
|
generateLlmsTxt(domain, data, options) {
|
|
3761
3766
|
const pages = this.generateAllMatrices(domain, data, options);
|
|
3762
3767
|
const lines = [
|
|
@@ -3771,6 +3776,91 @@ ${urls}
|
|
|
3771
3776
|
return lines.join(`
|
|
3772
3777
|
`);
|
|
3773
3778
|
}
|
|
3779
|
+
generateRobotsTxt(domain, options) {
|
|
3780
|
+
const cleanDomain = domain.replace(/\/+$/, "");
|
|
3781
|
+
const sitemap = options?.sitemapUrl || `${cleanDomain}/sitemap.xml`;
|
|
3782
|
+
const disallows = options?.disallowPaths || ["/api/", "/admin/", "/private/"];
|
|
3783
|
+
const lines = [
|
|
3784
|
+
"User-agent: *",
|
|
3785
|
+
"Allow: /",
|
|
3786
|
+
...disallows.map((d) => `Disallow: ${d}`),
|
|
3787
|
+
""
|
|
3788
|
+
];
|
|
3789
|
+
if (options?.allowAiBots !== false) {
|
|
3790
|
+
lines.push("# AI Search Crawlers Optimization (AEO/GEO)", "User-agent: GPTBot", "Allow: /", "", "User-agent: ClaudeBot", "Allow: /", "", "User-agent: PerplexityBot", "Allow: /", "", "User-agent: Applebot", "Allow: /", "");
|
|
3791
|
+
}
|
|
3792
|
+
lines.push(`Sitemap: ${sitemap}`);
|
|
3793
|
+
return lines.join(`
|
|
3794
|
+
`);
|
|
3795
|
+
}
|
|
3796
|
+
getTelemetryPayload(domain, data, options) {
|
|
3797
|
+
const pages = this.generateAllMatrices(domain, data, options);
|
|
3798
|
+
const indexedPages = pages.filter((p) => p.robots.includes("index"));
|
|
3799
|
+
const matrixBreakdown = {};
|
|
3800
|
+
for (const p of pages) {
|
|
3801
|
+
matrixBreakdown[p.matrixFamily] = (matrixBreakdown[p.matrixFamily] || 0) + 1;
|
|
3802
|
+
}
|
|
3803
|
+
return {
|
|
3804
|
+
domain,
|
|
3805
|
+
brandName: options.brandName,
|
|
3806
|
+
language: options.language || "en",
|
|
3807
|
+
languages: options.languages || [options.language || "en"],
|
|
3808
|
+
totalPages: pages.length,
|
|
3809
|
+
indexedPages: indexedPages.length,
|
|
3810
|
+
servicesCount: data.services?.length ?? 0,
|
|
3811
|
+
matrixBreakdown,
|
|
3812
|
+
timestamp: new Date().toISOString()
|
|
3813
|
+
};
|
|
3814
|
+
}
|
|
3815
|
+
}
|
|
3816
|
+
// src/i18n-detector.ts
|
|
3817
|
+
var SUPPORTED_CANONICAL_LOCALES = [
|
|
3818
|
+
"en",
|
|
3819
|
+
"fr",
|
|
3820
|
+
"es",
|
|
3821
|
+
"de",
|
|
3822
|
+
"it",
|
|
3823
|
+
"pt",
|
|
3824
|
+
"nl",
|
|
3825
|
+
"ru",
|
|
3826
|
+
"sv",
|
|
3827
|
+
"pl",
|
|
3828
|
+
"ja",
|
|
3829
|
+
"zh",
|
|
3830
|
+
"ar"
|
|
3831
|
+
];
|
|
3832
|
+
|
|
3833
|
+
class I18nDetector {
|
|
3834
|
+
static normalizeLocale(localeInput) {
|
|
3835
|
+
if (!localeInput || typeof localeInput !== "string")
|
|
3836
|
+
return "en";
|
|
3837
|
+
const clean = localeInput.toLowerCase().split(/[-_]/)[0].trim();
|
|
3838
|
+
return clean || "en";
|
|
3839
|
+
}
|
|
3840
|
+
static resolveActiveLocales(explicitLocales) {
|
|
3841
|
+
if (explicitLocales) {
|
|
3842
|
+
const list = Array.isArray(explicitLocales) ? explicitLocales : [explicitLocales];
|
|
3843
|
+
const normalized = list.map((l) => this.normalizeLocale(l)).filter(Boolean);
|
|
3844
|
+
return Array.from(new Set(normalized));
|
|
3845
|
+
}
|
|
3846
|
+
return ["en", "fr"];
|
|
3847
|
+
}
|
|
3848
|
+
static extractLocaleFromPath(pathname, activeLocales) {
|
|
3849
|
+
const cleanPath = pathname.startsWith("/") ? pathname : `/${pathname}`;
|
|
3850
|
+
const segments = cleanPath.split("/").filter(Boolean);
|
|
3851
|
+
if (segments.length > 0 && activeLocales.includes(segments[0].toLowerCase())) {
|
|
3852
|
+
const locale = segments[0].toLowerCase();
|
|
3853
|
+
const remainingSegments = segments.slice(1);
|
|
3854
|
+
return {
|
|
3855
|
+
locale,
|
|
3856
|
+
remainingPath: `/${remainingSegments.join("/")}`
|
|
3857
|
+
};
|
|
3858
|
+
}
|
|
3859
|
+
return {
|
|
3860
|
+
locale: activeLocales[0] || "en",
|
|
3861
|
+
remainingPath: cleanPath
|
|
3862
|
+
};
|
|
3863
|
+
}
|
|
3774
3864
|
}
|
|
3775
3865
|
// src/brand-icons.ts
|
|
3776
3866
|
var BRAND_ICONS = {
|
|
@@ -6464,6 +6554,7 @@ export {
|
|
|
6464
6554
|
SerpClient,
|
|
6465
6555
|
SeoOpportunitiesDecayDetector,
|
|
6466
6556
|
SchemaGraphBuilder,
|
|
6557
|
+
SUPPORTED_CANONICAL_LOCALES,
|
|
6467
6558
|
SCHEMA_LOCAL_BUSINESS_MAP,
|
|
6468
6559
|
RssSyndicationFeedGenerator,
|
|
6469
6560
|
RealReviewsSyncEngine,
|
|
@@ -6487,6 +6578,7 @@ export {
|
|
|
6487
6578
|
IsrCacheManager,
|
|
6488
6579
|
InstantMatrixSearchEngine,
|
|
6489
6580
|
IndexNowClient,
|
|
6581
|
+
I18nDetector,
|
|
6490
6582
|
HyperswitchGateway,
|
|
6491
6583
|
GeoMeshLinkingEngine,
|
|
6492
6584
|
ExtendedSchemaGraphBuilder,
|
package/dist/matrix-engine.d.ts
CHANGED
|
@@ -221,8 +221,35 @@ export declare class PseoMatrixEngine {
|
|
|
221
221
|
* Generates a fully formatted XML Sitemap string.
|
|
222
222
|
*/
|
|
223
223
|
generateSitemapXml(domain: string, data: Parameters<PseoMatrixEngine["generateAllMatrices"]>[1], options: MatrixOptions): string;
|
|
224
|
+
/**
|
|
225
|
+
* Resolves a page for a specific module/service and city.
|
|
226
|
+
* Useful when organizing code as app/[service]/[city]/page.tsx
|
|
227
|
+
*/
|
|
228
|
+
resolveServicePage(serviceSlug: string, citySlug: string, domain: string, data: Parameters<PseoMatrixEngine["generateAllMatrices"]>[1], options: MatrixOptions): GeneratedPageMeta | undefined;
|
|
224
229
|
/**
|
|
225
230
|
* Generates an official /llms.txt Markdown directory for AI search bots.
|
|
226
231
|
*/
|
|
227
232
|
generateLlmsTxt(domain: string, data: Parameters<PseoMatrixEngine["generateAllMatrices"]>[1], options: MatrixOptions): string;
|
|
233
|
+
/**
|
|
234
|
+
* Generates a modern robots.txt allowing search engines and AI agents with sitemap link.
|
|
235
|
+
*/
|
|
236
|
+
generateRobotsTxt(domain: string, options?: {
|
|
237
|
+
sitemapUrl?: string;
|
|
238
|
+
allowAiBots?: boolean;
|
|
239
|
+
disallowPaths?: string[];
|
|
240
|
+
}): string;
|
|
241
|
+
/**
|
|
242
|
+
* Computes telemetry & page metering data for syncing with LynxSEO Studio dashboard.
|
|
243
|
+
*/
|
|
244
|
+
getTelemetryPayload(domain: string, data: Parameters<PseoMatrixEngine["generateAllMatrices"]>[1], options: MatrixOptions): {
|
|
245
|
+
domain: string;
|
|
246
|
+
brandName: string;
|
|
247
|
+
language: string;
|
|
248
|
+
languages: string[];
|
|
249
|
+
totalPages: number;
|
|
250
|
+
indexedPages: number;
|
|
251
|
+
servicesCount: number;
|
|
252
|
+
matrixBreakdown: Record<string, number>;
|
|
253
|
+
timestamp: string;
|
|
254
|
+
};
|
|
228
255
|
}
|
package/package.json
CHANGED
package/src/engine.test.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { getSeoAgentPrompt } from "./llm-prompt";
|
|
|
7
7
|
import { UrlyticsEngine } from "./urlytics-engine";
|
|
8
8
|
import { KeywordPermutatorEngine } from "./keyword-permutator";
|
|
9
9
|
import { renderBrandIconSvg } from "./brand-icons";
|
|
10
|
+
import { I18nDetector } from "./i18n-detector";
|
|
10
11
|
|
|
11
12
|
describe("Multilingual SEO Slug Engine (10+ Languages)", () => {
|
|
12
13
|
it("English: Strips stop words and years", () => {
|
|
@@ -234,4 +235,34 @@ describe("Audited Reference Engines (Advertools, Santifer, Seonaut)", () => {
|
|
|
234
235
|
expect(paths).toContain("/autopost-facebook/madrid");
|
|
235
236
|
expect(paths).toContain("/autopost-facebook/barcelona");
|
|
236
237
|
});
|
|
238
|
+
|
|
239
|
+
it("I18nDetector: Normalizes locales and extracts locale from pathname", () => {
|
|
240
|
+
expect(I18nDetector.normalizeLocale("fr-FR")).toBe("fr");
|
|
241
|
+
expect(I18nDetector.normalizeLocale("en_US")).toBe("en");
|
|
242
|
+
|
|
243
|
+
const extraction = I18nDetector.extractLocaleFromPath("/fr/autopost-facebook/paris", ["fr", "en", "es"]);
|
|
244
|
+
expect(extraction.locale).toBe("fr");
|
|
245
|
+
expect(extraction.remainingPath).toBe("/autopost-facebook/paris");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("PseoMatrixEngine: resolveServicePage, generateRobotsTxt, and getTelemetryPayload", () => {
|
|
249
|
+
const engine = new PseoMatrixEngine();
|
|
250
|
+
const data = {
|
|
251
|
+
services: [{ slug: "crm", name: "CRM", category: "Sales" }],
|
|
252
|
+
locations: [{ slug: "paris", name: "Paris", country: "France" }],
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const servicePage = engine.resolveServicePage("crm", "paris", "https://acme.com", data, { brandName: "Acme" });
|
|
256
|
+
expect(servicePage).toBeDefined();
|
|
257
|
+
expect(servicePage?.h1).toContain("CRM");
|
|
258
|
+
|
|
259
|
+
const robots = engine.generateRobotsTxt("https://acme.com", { allowAiBots: true });
|
|
260
|
+
expect(robots).toContain("User-agent: GPTBot");
|
|
261
|
+
expect(robots).toContain("Sitemap: https://acme.com/sitemap.xml");
|
|
262
|
+
|
|
263
|
+
const telemetry = engine.getTelemetryPayload("https://acme.com", data, { brandName: "Acme" });
|
|
264
|
+
expect(telemetry.totalPages).toBeGreaterThan(0);
|
|
265
|
+
expect(telemetry.brandName).toBe("Acme");
|
|
266
|
+
expect(telemetry.servicesCount).toBe(1);
|
|
267
|
+
});
|
|
237
268
|
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 🌐 Dynamic i18n & Locale Auto-Detector
|
|
3
|
+
* Automatically detects and synchronizes website locales without requiring hardcoded lists.
|
|
4
|
+
* Compatible with next-intl, next-i18next, i18next, Paraglide, and Astro i18n.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const SUPPORTED_CANONICAL_LOCALES = [
|
|
8
|
+
"en",
|
|
9
|
+
"fr",
|
|
10
|
+
"es",
|
|
11
|
+
"de",
|
|
12
|
+
"it",
|
|
13
|
+
"pt",
|
|
14
|
+
"nl",
|
|
15
|
+
"ru",
|
|
16
|
+
"sv",
|
|
17
|
+
"pl",
|
|
18
|
+
"ja",
|
|
19
|
+
"zh",
|
|
20
|
+
"ar",
|
|
21
|
+
] as const;
|
|
22
|
+
|
|
23
|
+
export type SupportedLocale = (typeof SUPPORTED_CANONICAL_LOCALES)[number];
|
|
24
|
+
|
|
25
|
+
export class I18nDetector {
|
|
26
|
+
/**
|
|
27
|
+
* Resolves a clean 2-letter ISO language code from any locale string (e.g. "fr-FR" -> "fr").
|
|
28
|
+
*/
|
|
29
|
+
static normalizeLocale(localeInput?: string): string {
|
|
30
|
+
if (!localeInput || typeof localeInput !== "string") return "en";
|
|
31
|
+
const clean = localeInput.toLowerCase().split(/[-_]/)[0].trim();
|
|
32
|
+
return clean || "en";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Auto-detects and extracts active locales from common environment variables or array inputs.
|
|
37
|
+
*/
|
|
38
|
+
static resolveActiveLocales(explicitLocales?: string[] | string): string[] {
|
|
39
|
+
if (explicitLocales) {
|
|
40
|
+
const list = Array.isArray(explicitLocales) ? explicitLocales : [explicitLocales];
|
|
41
|
+
const normalized = list.map((l) => this.normalizeLocale(l)).filter(Boolean);
|
|
42
|
+
return Array.from(new Set(normalized));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Default fallback
|
|
46
|
+
return ["en", "fr"];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Matches whether a given path segment is a language prefix (e.g. "/fr/autopost-facebook/paris").
|
|
51
|
+
*/
|
|
52
|
+
static extractLocaleFromPath(pathname: string, activeLocales: string[]): { locale: string; remainingPath: string } {
|
|
53
|
+
const cleanPath = pathname.startsWith("/") ? pathname : `/${pathname}`;
|
|
54
|
+
const segments = cleanPath.split("/").filter(Boolean);
|
|
55
|
+
|
|
56
|
+
if (segments.length > 0 && activeLocales.includes(segments[0].toLowerCase())) {
|
|
57
|
+
const locale = segments[0].toLowerCase();
|
|
58
|
+
const remainingSegments = segments.slice(1);
|
|
59
|
+
return {
|
|
60
|
+
locale,
|
|
61
|
+
remainingPath: `/${remainingSegments.join("/")}`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
locale: activeLocales[0] || "en",
|
|
67
|
+
remainingPath: cleanPath,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ export * from "./slug-engine";
|
|
|
15
15
|
export * from "./legal-disclaimers";
|
|
16
16
|
export * from "./matrix-engine";
|
|
17
17
|
export * from "./built-in-locations";
|
|
18
|
+
export * from "./i18n-detector";
|
|
18
19
|
export * from "./brand-icons";
|
|
19
20
|
export * from "./urlytics-engine";
|
|
20
21
|
export * from "./keyword-permutator";
|
package/src/matrix-engine.ts
CHANGED
|
@@ -883,6 +883,22 @@ ${urls}
|
|
|
883
883
|
</urlset>`;
|
|
884
884
|
}
|
|
885
885
|
|
|
886
|
+
/**
|
|
887
|
+
* Resolves a page for a specific module/service and city.
|
|
888
|
+
* Useful when organizing code as app/[service]/[city]/page.tsx
|
|
889
|
+
*/
|
|
890
|
+
resolveServicePage(
|
|
891
|
+
serviceSlug: string,
|
|
892
|
+
citySlug: string,
|
|
893
|
+
domain: string,
|
|
894
|
+
data: Parameters<PseoMatrixEngine["generateAllMatrices"]>[1],
|
|
895
|
+
options: MatrixOptions,
|
|
896
|
+
): GeneratedPageMeta | undefined {
|
|
897
|
+
const cleanService = cleanSeoSlug(serviceSlug, { language: options.language });
|
|
898
|
+
const cleanCity = cleanSeoSlug(citySlug, { language: options.language });
|
|
899
|
+
return this.resolvePage([cleanService, cleanCity], domain, data, options);
|
|
900
|
+
}
|
|
901
|
+
|
|
886
902
|
/**
|
|
887
903
|
* Generates an official /llms.txt Markdown directory for AI search bots.
|
|
888
904
|
*/
|
|
@@ -905,4 +921,73 @@ ${urls}
|
|
|
905
921
|
|
|
906
922
|
return lines.join("\n");
|
|
907
923
|
}
|
|
924
|
+
|
|
925
|
+
/**
|
|
926
|
+
* Generates a modern robots.txt allowing search engines and AI agents with sitemap link.
|
|
927
|
+
*/
|
|
928
|
+
generateRobotsTxt(
|
|
929
|
+
domain: string,
|
|
930
|
+
options?: { sitemapUrl?: string; allowAiBots?: boolean; disallowPaths?: string[] },
|
|
931
|
+
): string {
|
|
932
|
+
const cleanDomain = domain.replace(/\/+$/, "");
|
|
933
|
+
const sitemap = options?.sitemapUrl || `${cleanDomain}/sitemap.xml`;
|
|
934
|
+
const disallows = options?.disallowPaths || ["/api/", "/admin/", "/private/"];
|
|
935
|
+
|
|
936
|
+
const lines: string[] = [
|
|
937
|
+
"User-agent: *",
|
|
938
|
+
"Allow: /",
|
|
939
|
+
...disallows.map((d) => `Disallow: ${d}`),
|
|
940
|
+
"",
|
|
941
|
+
];
|
|
942
|
+
|
|
943
|
+
if (options?.allowAiBots !== false) {
|
|
944
|
+
lines.push(
|
|
945
|
+
"# AI Search Crawlers Optimization (AEO/GEO)",
|
|
946
|
+
"User-agent: GPTBot",
|
|
947
|
+
"Allow: /",
|
|
948
|
+
"",
|
|
949
|
+
"User-agent: ClaudeBot",
|
|
950
|
+
"Allow: /",
|
|
951
|
+
"",
|
|
952
|
+
"User-agent: PerplexityBot",
|
|
953
|
+
"Allow: /",
|
|
954
|
+
"",
|
|
955
|
+
"User-agent: Applebot",
|
|
956
|
+
"Allow: /",
|
|
957
|
+
"",
|
|
958
|
+
);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
lines.push(`Sitemap: ${sitemap}`);
|
|
962
|
+
return lines.join("\n");
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/**
|
|
966
|
+
* Computes telemetry & page metering data for syncing with LynxSEO Studio dashboard.
|
|
967
|
+
*/
|
|
968
|
+
getTelemetryPayload(
|
|
969
|
+
domain: string,
|
|
970
|
+
data: Parameters<PseoMatrixEngine["generateAllMatrices"]>[1],
|
|
971
|
+
options: MatrixOptions,
|
|
972
|
+
) {
|
|
973
|
+
const pages = this.generateAllMatrices(domain, data, options);
|
|
974
|
+
const indexedPages = pages.filter((p) => p.robots.includes("index"));
|
|
975
|
+
|
|
976
|
+
const matrixBreakdown: Record<string, number> = {};
|
|
977
|
+
for (const p of pages) {
|
|
978
|
+
matrixBreakdown[p.matrixFamily] = (matrixBreakdown[p.matrixFamily] || 0) + 1;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
return {
|
|
982
|
+
domain,
|
|
983
|
+
brandName: options.brandName,
|
|
984
|
+
language: options.language || "en",
|
|
985
|
+
languages: options.languages || [options.language || "en"],
|
|
986
|
+
totalPages: pages.length,
|
|
987
|
+
indexedPages: indexedPages.length,
|
|
988
|
+
servicesCount: data.services?.length ?? 0,
|
|
989
|
+
matrixBreakdown,
|
|
990
|
+
timestamp: new Date().toISOString(),
|
|
991
|
+
};
|
|
992
|
+
}
|
|
908
993
|
}
|