@devslab/site-kit 0.4.1 → 0.5.1

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.
@@ -15,22 +15,89 @@ export const LOCALES = Object.freeze([
15
15
  { code: "ar", language: "Arabic", nativeName: "العربية", dir: "rtl" },
16
16
  ]);
17
17
 
18
- const byLower = new Map(LOCALES.map((locale) => [locale.code.toLowerCase(), locale]));
19
- const aliases = new Map([["zh", "zh-TW"], ["pt", "pt-BR"]]);
18
+ const FAMILY_ALIASES = Object.freeze([["zh", "zh-TW"], ["pt", "pt-BR"]]);
20
19
 
21
- export function canonicalLocale(candidate) {
22
- if (!candidate) return undefined;
23
- const exact = byLower.get(String(candidate).trim().toLowerCase());
24
- if (exact) return exact.code;
25
- const base = String(candidate).trim().toLowerCase().split("-")[0];
26
- if (aliases.has(base)) return aliases.get(base);
27
- return byLower.get(base)?.code;
28
- }
20
+ /**
21
+ * The languages a site actually sells in.
22
+ *
23
+ * The list above is the family's — what devslab.kr markets in, and the
24
+ * floor every product gets for free. It is not every product's list.
25
+ * BookLinq sells to salons in India and its assistant already answers in
26
+ * Tamil, Telugu, Bengali, Marathi, Gujarati and Kannada. Those are not
27
+ * family languages and are not going to become family languages: putting
28
+ * them in the list above would hand AskLinq and devslab.kr six entries
29
+ * they have no copy for. But a BookLinq page that cannot render them is a
30
+ * page that lies about what the product does.
31
+ *
32
+ * So the family owns the mechanism and the product names its own nouns. A
33
+ * registry is the family list plus whatever a product adds, and every
34
+ * locale-aware helper here can be bound to one. The bare exports at the
35
+ * bottom are the family registry, so a consumer that never calls this
36
+ * sees exactly what it saw before.
37
+ *
38
+ * An extra locale carries the same fields as a family one plus
39
+ * `flagCountry`, naming a flag this package already vendors (`FLAG_COUNTRY`
40
+ * lists them). Products do not ship SVG: flag data is licensed, generated
41
+ * and security-scanned here, and seven Indian languages share one flag
42
+ * anyway.
43
+ *
44
+ * @param {{ extra?: ReadonlyArray<object>, aliases?: ReadonlyArray<[string, string]> }} [options]
45
+ */
46
+ export function defineLocaleRegistry(options = {}) {
47
+ const extra = options.extra ?? [];
48
+ const seen = new Set(LOCALES.map(({ code }) => code));
49
+ for (const locale of extra) {
50
+ if (!locale?.code) throw new TypeError("every extra locale needs a code");
51
+ if (seen.has(locale.code)) {
52
+ throw new RangeError(`${locale.code} is already a family locale — remove it from \`extra\``);
53
+ }
54
+ if (!locale.nativeName) throw new TypeError(`${locale.code} needs a nativeName — it is what the picker shows`);
55
+ if (locale.dir !== "ltr" && locale.dir !== "rtl") throw new RangeError(`${locale.code} needs dir "ltr" or "rtl"`);
56
+ seen.add(locale.code);
57
+ }
58
+
59
+ const locales = Object.freeze([...LOCALES, ...extra.map((locale) => Object.freeze({ ...locale }))]);
60
+ const byLower = new Map(locales.map((locale) => [locale.code.toLowerCase(), locale]));
61
+ const aliases = new Map([...FAMILY_ALIASES, ...(options.aliases ?? [])]);
62
+
63
+ function canonicalLocale(candidate) {
64
+ if (!candidate) return undefined;
65
+ const exact = byLower.get(String(candidate).trim().toLowerCase());
66
+ if (exact) return exact.code;
67
+ const base = String(candidate).trim().toLowerCase().split("-")[0];
68
+ if (aliases.has(base)) return aliases.get(base);
69
+ return byLower.get(base)?.code;
70
+ }
71
+
72
+ function definitionFor(locale) {
73
+ const canonical = canonicalLocale(locale);
74
+ if (!canonical) throw new RangeError(`Unsupported locale: ${locale}`);
75
+ return byLower.get(canonical.toLowerCase());
76
+ }
77
+
78
+ function localeAttributes(locale) {
79
+ const definition = definitionFor(locale);
80
+ // `dir` comes off the definition rather than a test for Arabic. The
81
+ // old form read `canonical === "ar" ? "rtl" : "ltr"`, which was right
82
+ // only while Arabic stayed the family's one RTL language — a product
83
+ // adding Urdu or Hebrew would have had it rendered left-to-right with
84
+ // every test still green.
85
+ return { lang: definition.code, dir: definition.dir };
86
+ }
29
87
 
30
- export function localeAttributes(locale) {
31
- const canonical = canonicalLocale(locale);
32
- if (!canonical) throw new RangeError(`Unsupported locale: ${locale}`);
33
- return { lang: canonical, dir: canonical === "ar" ? "rtl" : "ltr" };
88
+ function resolveLocale({ pathname = "/", cookie = "", acceptLanguage = "", defaultLocale }) {
89
+ const routeLocale = canonicalLocale(pathname.split("/").filter(Boolean)[0]);
90
+ if (routeLocale) return { locale: routeLocale, source: "route" };
91
+ const storedLocale = canonicalLocale(cookieValue(cookie, "locale"));
92
+ if (storedLocale) return { locale: storedLocale, source: "cookie" };
93
+ const accepted = acceptedLocales(acceptLanguage, canonicalLocale)[0]?.locale;
94
+ if (accepted) return { locale: accepted, source: "accept-language" };
95
+ const fallback = canonicalLocale(defaultLocale);
96
+ if (!fallback) throw new RangeError(`Unsupported default locale: ${defaultLocale}`);
97
+ return { locale: fallback, source: "default" };
98
+ }
99
+
100
+ return Object.freeze({ LOCALES: locales, canonicalLocale, localeAttributes, resolveLocale, definitionFor });
34
101
  }
35
102
 
36
103
  function cookieValue(header, name) {
@@ -45,7 +112,7 @@ function cookieValue(header, name) {
45
112
  return undefined;
46
113
  }
47
114
 
48
- function acceptedLocales(header) {
115
+ function acceptedLocales(header, canonicalLocale) {
49
116
  return String(header ?? "").split(",").map((part, order) => {
50
117
  const [tag, ...parameters] = part.trim().split(";");
51
118
  const q = Number(parameters.find((parameter) => parameter.trim().startsWith("q="))?.split("=")[1] ?? 1);
@@ -53,14 +120,7 @@ function acceptedLocales(header) {
53
120
  }).filter(({ locale, q }) => locale && q > 0).sort((a, b) => b.q - a.q || a.order - b.order);
54
121
  }
55
122
 
56
- export function resolveLocale({ pathname = "/", cookie = "", acceptLanguage = "", defaultLocale }) {
57
- const routeLocale = canonicalLocale(pathname.split("/").filter(Boolean)[0]);
58
- if (routeLocale) return { locale: routeLocale, source: "route" };
59
- const storedLocale = canonicalLocale(cookieValue(cookie, "locale"));
60
- if (storedLocale) return { locale: storedLocale, source: "cookie" };
61
- const accepted = acceptedLocales(acceptLanguage)[0]?.locale;
62
- if (accepted) return { locale: accepted, source: "accept-language" };
63
- const fallback = canonicalLocale(defaultLocale);
64
- if (!fallback) throw new RangeError(`Unsupported default locale: ${defaultLocale}`);
65
- return { locale: fallback, source: "default" };
66
- }
123
+ /** The family registry — what every helper in this package uses by default. */
124
+ export const FAMILY_LOCALES = defineLocaleRegistry();
125
+
126
+ export const { canonicalLocale, localeAttributes, resolveLocale } = FAMILY_LOCALES;
@@ -1,16 +1,16 @@
1
- import type { SiteLocale, TextDirection } from "./locales.mjs";
2
- export interface MetadataInput { baseUrl: string; path: string; locale: SiteLocale; defaultLocale: SiteLocale; title: string; description: string; siteName: string; image: string }
3
- export interface SiteMetadata {
4
- html: { lang: SiteLocale; dir: TextDirection };
1
+ import type { LocaleRegistry, SiteLocale, TextDirection } from "./locales.mjs";
2
+ export interface MetadataInput<Code extends string = SiteLocale> { baseUrl: string; path: string; locale: Code; defaultLocale: Code; title: string; description: string; siteName: string; image: string; registry?: LocaleRegistry<Code> }
3
+ export interface SiteMetadata<Code extends string = SiteLocale> {
4
+ html: { lang: Code; dir: TextDirection };
5
5
  title: string; description: string; canonical: string;
6
- alternates: Array<{ hreflang: SiteLocale | "x-default"; href: string }>;
7
- openGraph: { type: "website"; locale: SiteLocale; url: string; siteName: string; title: string; description: string; images: Array<{ url: string }> };
6
+ alternates: Array<{ hreflang: Code | "x-default"; href: string }>;
7
+ openGraph: { type: "website"; locale: Code; url: string; siteName: string; title: string; description: string; images: Array<{ url: string }> };
8
8
  twitter: { card: "summary_large_image"; title: string; description: string; image: string };
9
9
  }
10
- export declare function localizedPath(path: string, locale: SiteLocale, defaultLocale: SiteLocale): string;
11
- export declare function localizedUrl(baseUrl: string, path: string, locale: SiteLocale, defaultLocale: SiteLocale): string;
12
- export declare function buildMetadata(input: MetadataInput): SiteMetadata;
13
- export declare function buildSitemap(input: { baseUrl: string; routes: string[]; defaultLocale: SiteLocale; lastModified?: string }): Array<{ loc: string; locale: SiteLocale; alternates: Array<{ hreflang: SiteLocale | "x-default"; href: string }>; lastmod?: string }>;
10
+ export declare function localizedPath<Code extends string = SiteLocale>(path: string, locale: Code, defaultLocale: Code, registry?: LocaleRegistry<Code>): string;
11
+ export declare function localizedUrl<Code extends string = SiteLocale>(baseUrl: string, path: string, locale: Code, defaultLocale: Code, registry?: LocaleRegistry<Code>): string;
12
+ export declare function buildMetadata<Code extends string = SiteLocale>(input: MetadataInput<Code>): SiteMetadata<Code>;
13
+ export declare function buildSitemap<Code extends string = SiteLocale>(input: { baseUrl: string; routes: string[]; defaultLocale: Code; lastModified?: string; registry?: LocaleRegistry<Code> }): Array<{ loc: string; locale: Code; alternates: Array<{ hreflang: Code | "x-default"; href: string }>; lastmod?: string }>;
14
14
  export declare function renderSitemapXml(entries: ReturnType<typeof buildSitemap>): string;
15
15
  export type RobotsPolicy = "allow" | "disallow";
16
16
  export declare const ROBOTS_USER_AGENTS: { readonly citation: readonly string[]; readonly modelTraining: readonly string[] };
package/src/core/seo.mjs CHANGED
@@ -1,4 +1,10 @@
1
- import { LOCALES, canonicalLocale, localeAttributes } from "./locales.mjs";
1
+ import { FAMILY_LOCALES } from "./locales.mjs";
2
+
3
+ // Every function here that names a locale takes an optional `registry`.
4
+ // Omitted it is the family's fourteen, which is what a family site wants.
5
+ // A product that added its own languages must pass its registry, or the
6
+ // hreflang alternates and the sitemap would quietly omit them — the page
7
+ // would render in Tamil while telling search engines Tamil does not exist.
2
8
 
3
9
  const cleanBase = (baseUrl) => String(baseUrl).replace(/\/+$/, "");
4
10
  const cleanPath = (path) => {
@@ -6,27 +12,27 @@ const cleanPath = (path) => {
6
12
  return value === "/" ? "/" : value;
7
13
  };
8
14
 
9
- export function localizedPath(path, locale, defaultLocale) {
10
- const canonical = canonicalLocale(locale);
11
- const fallback = canonicalLocale(defaultLocale);
15
+ export function localizedPath(path, locale, defaultLocale, registry = FAMILY_LOCALES) {
16
+ const canonical = registry.canonicalLocale(locale);
17
+ const fallback = registry.canonicalLocale(defaultLocale);
12
18
  if (!canonical || !fallback) throw new RangeError("localizedPath received an unsupported locale");
13
19
  const normalized = cleanPath(path);
14
20
  return canonical === fallback ? normalized : `/${canonical}${normalized === "/" ? "" : normalized}`;
15
21
  }
16
22
 
17
- export function localizedUrl(baseUrl, path, locale, defaultLocale) {
18
- return `${cleanBase(baseUrl)}${localizedPath(path, locale, defaultLocale)}`;
23
+ export function localizedUrl(baseUrl, path, locale, defaultLocale, registry = FAMILY_LOCALES) {
24
+ return `${cleanBase(baseUrl)}${localizedPath(path, locale, defaultLocale, registry)}`;
19
25
  }
20
26
 
21
- export function buildMetadata({ baseUrl, path, locale, defaultLocale, title, description, siteName, image }) {
22
- const canonicalLocaleCode = canonicalLocale(locale);
27
+ export function buildMetadata({ baseUrl, path, locale, defaultLocale, title, description, siteName, image, registry = FAMILY_LOCALES }) {
28
+ const canonicalLocaleCode = registry.canonicalLocale(locale);
23
29
  if (!canonicalLocaleCode) throw new RangeError(`Unsupported locale: ${locale}`);
24
- const canonical = localizedUrl(baseUrl, path, canonicalLocaleCode, defaultLocale);
25
- const alternates = LOCALES.map(({ code }) => ({ hreflang: code, href: localizedUrl(baseUrl, path, code, defaultLocale) }));
26
- alternates.push({ hreflang: "x-default", href: localizedUrl(baseUrl, path, defaultLocale, defaultLocale) });
30
+ const canonical = localizedUrl(baseUrl, path, canonicalLocaleCode, defaultLocale, registry);
31
+ const alternates = registry.LOCALES.map(({ code }) => ({ hreflang: code, href: localizedUrl(baseUrl, path, code, defaultLocale, registry) }));
32
+ alternates.push({ hreflang: "x-default", href: localizedUrl(baseUrl, path, defaultLocale, defaultLocale, registry) });
27
33
  const imageUrl = new URL(image, `${cleanBase(baseUrl)}/`).href;
28
34
  return {
29
- html: localeAttributes(canonicalLocaleCode),
35
+ html: registry.localeAttributes(canonicalLocaleCode),
30
36
  title,
31
37
  description,
32
38
  canonical,
@@ -44,13 +50,13 @@ export function buildMetadata({ baseUrl, path, locale, defaultLocale, title, des
44
50
  };
45
51
  }
46
52
 
47
- export function buildSitemap({ baseUrl, routes, defaultLocale, lastModified }) {
48
- return routes.flatMap((path) => LOCALES.map(({ code }) => ({
49
- loc: localizedUrl(baseUrl, path, code, defaultLocale),
53
+ export function buildSitemap({ baseUrl, routes, defaultLocale, lastModified, registry = FAMILY_LOCALES }) {
54
+ return routes.flatMap((path) => registry.LOCALES.map(({ code }) => ({
55
+ loc: localizedUrl(baseUrl, path, code, defaultLocale, registry),
50
56
  locale: code,
51
57
  alternates: [
52
- ...LOCALES.map(({ code: alternate }) => ({ hreflang: alternate, href: localizedUrl(baseUrl, path, alternate, defaultLocale) })),
53
- { hreflang: "x-default", href: localizedUrl(baseUrl, path, defaultLocale, defaultLocale) },
58
+ ...registry.LOCALES.map(({ code: alternate }) => ({ hreflang: alternate, href: localizedUrl(baseUrl, path, alternate, defaultLocale, registry) })),
59
+ { hreflang: "x-default", href: localizedUrl(baseUrl, path, defaultLocale, defaultLocale, registry) },
54
60
  ],
55
61
  ...(lastModified ? { lastmod: lastModified } : {}),
56
62
  })));
@@ -1,3 +1,10 @@
1
1
  import type { SiteMetadata } from "./core/seo.mjs";
2
- export declare function toTanStackHead(metadata: SiteMetadata): { meta: Array<Record<string, string>>; links: Array<Record<string, string>> };
3
- export declare const toHtmlAttributes: (metadata: SiteMetadata) => { lang: string; dir: string };
2
+ import type { SiteLocale } from "./core/locales.mjs";
3
+
4
+ /**
5
+ * Generic over the locale code, like the builders that feed it. A product
6
+ * registry (D-018) yields `SiteMetadata<string>`; the adapter reads the
7
+ * same fields whatever the code type is, so it must not refuse that.
8
+ */
9
+ export declare function toTanStackHead<Code extends string = SiteLocale>(metadata: SiteMetadata<Code>): { meta: Array<Record<string, string>>; links: Array<Record<string, string>> };
10
+ export declare const toHtmlAttributes: <Code extends string = SiteLocale>(metadata: SiteMetadata<Code>) => { lang: Code; dir: string };