@devslab/site-kit 0.3.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 +74 -0
- package/README.ko.md +18 -0
- package/README.md +18 -0
- package/dist/chrome.d.ts +35 -0
- package/dist/chrome.d.ts.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/layouts.d.ts +43 -0
- package/dist/layouts.d.ts.map +1 -0
- package/dist/oss-product-mark.d.ts +9 -0
- package/dist/oss-product-mark.d.ts.map +1 -0
- package/dist/request-access.d.ts +17 -0
- package/dist/request-access.d.ts.map +1 -0
- package/dist/solid.js +404 -0
- package/dist/solid.js.map +1 -0
- package/dist/solid.server.js +404 -0
- package/dist/solid.server.js.map +1 -0
- package/dist/types.d.ts +37 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +87 -0
- package/src/core/catalog.d.mts +5 -0
- package/src/core/catalog.mjs +45 -0
- package/src/core/geo.d.mts +10 -0
- package/src/core/geo.mjs +69 -0
- package/src/core/index.d.mts +4 -0
- package/src/core/index.mjs +4 -0
- package/src/core/locales.d.mts +7 -0
- package/src/core/locales.mjs +66 -0
- package/src/core/seo.d.mts +17 -0
- package/src/core/seo.mjs +88 -0
- package/src/tanstack-start.d.mts +3 -0
- package/src/tanstack-start.mjs +25 -0
- package/styles.css +44 -0
package/package.json
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@devslab/site-kit",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Strict i18n, SEO/GEO, and Solid public-site shells for DevsLab products",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
7
|
+
"sideEffects": [
|
|
8
|
+
"./styles.css"
|
|
9
|
+
],
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "https://github.com/devslab-kr/dds.git",
|
|
13
|
+
"directory": "packages/site-kit"
|
|
14
|
+
},
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"registry": "https://registry.npmjs.org",
|
|
17
|
+
"access": "public",
|
|
18
|
+
"provenance": true
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist",
|
|
22
|
+
"src/core",
|
|
23
|
+
"src/tanstack-start.mjs",
|
|
24
|
+
"src/tanstack-start.d.mts",
|
|
25
|
+
"styles.css",
|
|
26
|
+
"README.md",
|
|
27
|
+
"README.ko.md"
|
|
28
|
+
],
|
|
29
|
+
"main": "./src/core/index.mjs",
|
|
30
|
+
"types": "./src/core/index.d.mts",
|
|
31
|
+
"exports": {
|
|
32
|
+
".": {
|
|
33
|
+
"types": "./src/core/index.d.mts",
|
|
34
|
+
"import": "./src/core/index.mjs"
|
|
35
|
+
},
|
|
36
|
+
"./solid": {
|
|
37
|
+
"types": "./dist/index.d.ts",
|
|
38
|
+
"worker": "./dist/solid.server.js",
|
|
39
|
+
"workerd": "./dist/solid.server.js",
|
|
40
|
+
"node": "./dist/solid.server.js",
|
|
41
|
+
"browser": "./dist/solid.js",
|
|
42
|
+
"default": "./dist/solid.js"
|
|
43
|
+
},
|
|
44
|
+
"./tanstack-start": {
|
|
45
|
+
"types": "./src/tanstack-start.d.mts",
|
|
46
|
+
"import": "./src/tanstack-start.mjs"
|
|
47
|
+
},
|
|
48
|
+
"./styles.css": "./styles.css"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"@devslab/dds-solid": "0.3.0"
|
|
52
|
+
},
|
|
53
|
+
"peerDependencies": {
|
|
54
|
+
"@tanstack/solid-router": "1.170.30",
|
|
55
|
+
"@tanstack/solid-start": "1.168.47",
|
|
56
|
+
"solid-js": "1.9.15"
|
|
57
|
+
},
|
|
58
|
+
"peerDependenciesMeta": {
|
|
59
|
+
"@tanstack/solid-router": {
|
|
60
|
+
"optional": true
|
|
61
|
+
},
|
|
62
|
+
"@tanstack/solid-start": {
|
|
63
|
+
"optional": true
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
"devDependencies": {
|
|
67
|
+
"@types/node": "26.3.0",
|
|
68
|
+
"@tanstack/solid-router": "1.170.30",
|
|
69
|
+
"@tanstack/solid-start": "1.168.47",
|
|
70
|
+
"axe-core": "4.13.0",
|
|
71
|
+
"jsdom": "30.0.1",
|
|
72
|
+
"solid-js": "1.9.15",
|
|
73
|
+
"@testing-library/jest-dom": "6.9.1",
|
|
74
|
+
"typescript": "7.0.2",
|
|
75
|
+
"vite": "8.2.1",
|
|
76
|
+
"vite-plugin-solid": "2.11.14",
|
|
77
|
+
"vitest": "4.1.1",
|
|
78
|
+
"wrangler": "4.127.1"
|
|
79
|
+
},
|
|
80
|
+
"scripts": {
|
|
81
|
+
"build": "vite build && vite build --config vite.server.config.ts && tsc -p tsconfig.build.json --emitDeclarationOnly",
|
|
82
|
+
"check": "tsc -p tsconfig.json --noEmit",
|
|
83
|
+
"test": "vitest run --config vitest.config.ts && vitest run --config vitest.ssr.config.ts",
|
|
84
|
+
"test:a11y": "vitest run --config vitest.config.ts src/solid/__tests__/a11y.test.tsx",
|
|
85
|
+
"test:worker": "wrangler deploy --dry-run --config fixtures/worker/wrangler.jsonc --outdir dist-worker"
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { SiteLocale } from "./locales.mjs";
|
|
2
|
+
export type MessageCatalog = Record<string, string>;
|
|
3
|
+
export declare class CatalogValidationError extends Error { readonly issues: readonly string[] }
|
|
4
|
+
export declare function validateCatalogs(catalogs: Record<SiteLocale, MessageCatalog>, referenceLocale: SiteLocale): true;
|
|
5
|
+
export declare function createTranslator(catalog: MessageCatalog, locale: SiteLocale): (key: string, values?: Record<string, string | number>) => string;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { LOCALES, canonicalLocale } from "./locales.mjs";
|
|
2
|
+
|
|
3
|
+
const placeholders = (message) => [...String(message).matchAll(/\{([A-Za-z][A-Za-z0-9_]*)\}/g)].map((match) => match[1]).sort();
|
|
4
|
+
const difference = (left, right) => left.filter((value) => !right.includes(value));
|
|
5
|
+
|
|
6
|
+
export class CatalogValidationError extends Error {
|
|
7
|
+
constructor(issues) {
|
|
8
|
+
super(`Catalog validation failed:\n${issues.map((issue) => `- ${issue}`).join("\n")}`);
|
|
9
|
+
this.name = "CatalogValidationError";
|
|
10
|
+
this.issues = issues;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function validateCatalogs(catalogs, referenceLocale) {
|
|
15
|
+
const reference = canonicalLocale(referenceLocale);
|
|
16
|
+
if (!reference || !catalogs[reference]) throw new CatalogValidationError([`reference locale ${referenceLocale} is missing`]);
|
|
17
|
+
const referenceKeys = Object.keys(catalogs[reference]).sort();
|
|
18
|
+
const issues = [];
|
|
19
|
+
for (const { code } of LOCALES) {
|
|
20
|
+
const catalog = catalogs[code];
|
|
21
|
+
if (!catalog) { issues.push(`missing locale ${code}`); continue; }
|
|
22
|
+
const keys = Object.keys(catalog).sort();
|
|
23
|
+
for (const key of difference(referenceKeys, keys)) issues.push(`${code} missing key ${key}`);
|
|
24
|
+
for (const key of difference(keys, referenceKeys)) issues.push(`${code} extra key ${key}`);
|
|
25
|
+
for (const key of referenceKeys.filter((candidate) => candidate in catalog)) {
|
|
26
|
+
if (typeof catalog[key] !== "string") { issues.push(`${code} key ${key} is not a string`); continue; }
|
|
27
|
+
const expected = placeholders(catalogs[reference][key]);
|
|
28
|
+
const actual = placeholders(catalog[key]);
|
|
29
|
+
if (expected.join("|") !== actual.join("|")) issues.push(`${code} placeholder mismatch for ${key}: expected ${expected.join(",")}; received ${actual.join(",")}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (issues.length) throw new CatalogValidationError(issues);
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createTranslator(catalog, locale) {
|
|
37
|
+
return (key, values = {}) => {
|
|
38
|
+
const message = catalog[key];
|
|
39
|
+
if (typeof message !== "string") throw new Error(`Missing translation ${locale}:${key}; runtime fallback is disabled`);
|
|
40
|
+
const required = placeholders(message);
|
|
41
|
+
for (const name of required) if (!(name in values)) throw new Error(`Missing placeholder ${name} for ${locale}:${key}`);
|
|
42
|
+
for (const name of Object.keys(values)) if (!required.includes(name)) throw new Error(`Extra placeholder ${name} for ${locale}:${key}`);
|
|
43
|
+
return message.replace(/\{([A-Za-z][A-Za-z0-9_]*)\}/g, (_, name) => String(values[name]));
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface VerifiedFact { id: string; value: string; sourceUrl: string; verifiedAt: string; expiresAt?: string }
|
|
2
|
+
export declare class VerifiedFactRegistry {
|
|
3
|
+
constructor(facts: VerifiedFact[], options?: { now?: string });
|
|
4
|
+
get(id: string): Readonly<VerifiedFact>;
|
|
5
|
+
all(): Array<Readonly<VerifiedFact>>;
|
|
6
|
+
}
|
|
7
|
+
export type FactClaim = { factId: string } | FactClaim[] | { [key: string]: FactClaim };
|
|
8
|
+
export type VerifiedSchemaType = "Organization" | "WebSite" | "SoftwareApplication" | "Product" | "BreadcrumbList" | "FAQPage" | "TechArticle";
|
|
9
|
+
export declare function buildVerifiedJsonLd(input: { type: VerifiedSchemaType; id: string; identity: { name: string; url: string; logo?: string }; claims?: Record<string, FactClaim> }, registry: VerifiedFactRegistry): Record<string, unknown>;
|
|
10
|
+
export declare function renderLlmsTxt(input: { title: string; summary: string; canonicalUrl: string; facts: VerifiedFactRegistry }): string;
|
package/src/core/geo.mjs
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
const isoDate = /^\d{4}-\d{2}-\d{2}$/;
|
|
2
|
+
const schemaClaims = new Map([
|
|
3
|
+
["Organization", new Set(["description", "sameAs", "contactPoint", "areaServed"])],
|
|
4
|
+
["WebSite", new Set(["description", "inLanguage", "publisher"])],
|
|
5
|
+
["SoftwareApplication", new Set(["description", "applicationCategory", "operatingSystem", "featureList", "offers"])],
|
|
6
|
+
["Product", new Set(["description", "category", "brand", "offers"])],
|
|
7
|
+
["BreadcrumbList", new Set(["itemListElement"])],
|
|
8
|
+
["FAQPage", new Set(["mainEntity"])],
|
|
9
|
+
["TechArticle", new Set(["headline", "description", "datePublished", "dateModified", "author", "about"])],
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
export class VerifiedFactRegistry {
|
|
13
|
+
#facts = new Map();
|
|
14
|
+
#now;
|
|
15
|
+
|
|
16
|
+
constructor(facts, { now = new Date().toISOString().slice(0, 10) } = {}) {
|
|
17
|
+
if (!isoDate.test(now)) throw new TypeError("now must be YYYY-MM-DD");
|
|
18
|
+
this.#now = now;
|
|
19
|
+
for (const fact of facts) {
|
|
20
|
+
if (!fact?.id || this.#facts.has(fact.id)) throw new Error(`Duplicate or missing fact id: ${fact?.id}`);
|
|
21
|
+
if (typeof fact.value !== "string" || !fact.value.trim()) throw new Error(`Fact ${fact.id} has no value`);
|
|
22
|
+
if (!String(fact.sourceUrl).startsWith("https://")) throw new Error(`Fact ${fact.id} requires an HTTPS sourceUrl`);
|
|
23
|
+
if (!isoDate.test(fact.verifiedAt) || fact.verifiedAt > this.#now) throw new Error(`Fact ${fact.id} has an invalid verifiedAt date`);
|
|
24
|
+
if (fact.expiresAt && (!isoDate.test(fact.expiresAt) || fact.expiresAt < this.#now)) throw new Error(`Fact ${fact.id} is expired`);
|
|
25
|
+
this.#facts.set(fact.id, Object.freeze({ ...fact }));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
get(id) {
|
|
30
|
+
const fact = this.#facts.get(id);
|
|
31
|
+
if (!fact) throw new Error(`Unverified fact: ${id}`);
|
|
32
|
+
return fact;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
all() { return [...this.#facts.values()]; }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function resolveClaim(value, registry) {
|
|
39
|
+
if (Array.isArray(value)) return value.map((entry) => resolveClaim(entry, registry));
|
|
40
|
+
if (value && typeof value === "object" && Object.keys(value).length === 1 && "factId" in value) return registry.get(value.factId).value;
|
|
41
|
+
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, resolveClaim(entry, registry)]));
|
|
42
|
+
throw new TypeError("GEO claims must reference a verified factId; literal claims are forbidden");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function buildVerifiedJsonLd({ type, id, identity, claims = {} }, registry) {
|
|
46
|
+
if (!(registry instanceof VerifiedFactRegistry)) throw new TypeError("A VerifiedFactRegistry is required");
|
|
47
|
+
if (!type || !id || !identity?.name || !identity?.url) throw new TypeError("Schema type, id, identity.name, and identity.url are required");
|
|
48
|
+
const allowedClaims = schemaClaims.get(type);
|
|
49
|
+
if (!allowedClaims) throw new TypeError(`Unsupported schema type: ${type}`);
|
|
50
|
+
for (const claim of Object.keys(claims)) {
|
|
51
|
+
if (!allowedClaims.has(claim)) throw new TypeError(`Unsupported claim ${claim} for schema type ${type}`);
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
"@context": "https://schema.org",
|
|
55
|
+
"@type": type,
|
|
56
|
+
"@id": id,
|
|
57
|
+
name: identity.name,
|
|
58
|
+
url: identity.url,
|
|
59
|
+
...(identity.logo ? { logo: identity.logo } : {}),
|
|
60
|
+
...Object.fromEntries(Object.entries(claims).map(([key, value]) => [key, resolveClaim(value, registry)])),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function renderLlmsTxt({ title, summary, canonicalUrl, facts }) {
|
|
65
|
+
if (!(facts instanceof VerifiedFactRegistry)) throw new TypeError("facts must be a VerifiedFactRegistry");
|
|
66
|
+
const lines = [`# ${title}`, "", `> ${summary}`, "", `Canonical: ${canonicalUrl}`, "", "## Verified facts", ""];
|
|
67
|
+
for (const fact of facts.all()) lines.push(`- ${fact.value} (source: ${fact.sourceUrl}; verified: ${fact.verifiedAt})`);
|
|
68
|
+
return `${lines.join("\n")}\n`;
|
|
69
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { LOCALES, canonicalLocale, localeAttributes, resolveLocale } from "./locales.mjs";
|
|
2
|
+
export { CatalogValidationError, createTranslator, validateCatalogs } from "./catalog.mjs";
|
|
3
|
+
export { ROBOTS_USER_AGENTS, buildMetadata, buildRobots, buildSitemap, localizedPath, localizedUrl, renderSitemapXml } from "./seo.mjs";
|
|
4
|
+
export { VerifiedFactRegistry, buildVerifiedJsonLd, renderLlmsTxt } from "./geo.mjs";
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export type SiteLocale = "ko" | "en" | "ja" | "zh-HK" | "zh-TW" | "hi" | "vi" | "id" | "th" | "pt-BR" | "fr" | "de" | "es" | "ar";
|
|
2
|
+
export type TextDirection = "ltr" | "rtl";
|
|
3
|
+
export interface LocaleDefinition { readonly code: SiteLocale; readonly language: string; readonly nativeName: string; readonly dir: TextDirection }
|
|
4
|
+
export declare const LOCALES: readonly LocaleDefinition[];
|
|
5
|
+
export declare function canonicalLocale(candidate: string | undefined | null): SiteLocale | undefined;
|
|
6
|
+
export declare function localeAttributes(locale: string): { lang: SiteLocale; dir: TextDirection };
|
|
7
|
+
export declare function resolveLocale(input: { pathname?: string; cookie?: string; acceptLanguage?: string; defaultLocale: SiteLocale }): { locale: SiteLocale; source: "route" | "cookie" | "accept-language" | "default" };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export const LOCALES = Object.freeze([
|
|
2
|
+
{ code: "ko", language: "Korean", nativeName: "한국어", dir: "ltr" },
|
|
3
|
+
{ code: "en", language: "English", nativeName: "English", dir: "ltr" },
|
|
4
|
+
{ code: "ja", language: "Japanese", nativeName: "日本語", dir: "ltr" },
|
|
5
|
+
{ code: "zh-HK", language: "Chinese (Hong Kong)", nativeName: "繁體中文(香港)", dir: "ltr" },
|
|
6
|
+
{ code: "zh-TW", language: "Chinese (Taiwan)", nativeName: "繁體中文(台灣)", dir: "ltr" },
|
|
7
|
+
{ code: "hi", language: "Hindi", nativeName: "हिन्दी", dir: "ltr" },
|
|
8
|
+
{ code: "vi", language: "Vietnamese", nativeName: "Tiếng Việt", dir: "ltr" },
|
|
9
|
+
{ code: "id", language: "Indonesian", nativeName: "Bahasa Indonesia", dir: "ltr" },
|
|
10
|
+
{ code: "th", language: "Thai", nativeName: "ไทย", dir: "ltr" },
|
|
11
|
+
{ code: "pt-BR", language: "Portuguese (Brazil)", nativeName: "Português (Brasil)", dir: "ltr" },
|
|
12
|
+
{ code: "fr", language: "French", nativeName: "Français", dir: "ltr" },
|
|
13
|
+
{ code: "de", language: "German", nativeName: "Deutsch", dir: "ltr" },
|
|
14
|
+
{ code: "es", language: "Spanish", nativeName: "Español", dir: "ltr" },
|
|
15
|
+
{ code: "ar", language: "Arabic", nativeName: "العربية", dir: "rtl" },
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
const byLower = new Map(LOCALES.map((locale) => [locale.code.toLowerCase(), locale]));
|
|
19
|
+
const aliases = new Map([["zh", "zh-TW"], ["pt", "pt-BR"]]);
|
|
20
|
+
|
|
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
|
+
}
|
|
29
|
+
|
|
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" };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function cookieValue(header, name) {
|
|
37
|
+
for (const part of String(header ?? "").split(";")) {
|
|
38
|
+
const separator = part.indexOf("=");
|
|
39
|
+
if (separator < 0) continue;
|
|
40
|
+
if (part.slice(0, separator).trim() === name) {
|
|
41
|
+
try { return decodeURIComponent(part.slice(separator + 1).trim()); }
|
|
42
|
+
catch { return undefined; }
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function acceptedLocales(header) {
|
|
49
|
+
return String(header ?? "").split(",").map((part, order) => {
|
|
50
|
+
const [tag, ...parameters] = part.trim().split(";");
|
|
51
|
+
const q = Number(parameters.find((parameter) => parameter.trim().startsWith("q="))?.split("=")[1] ?? 1);
|
|
52
|
+
return { locale: canonicalLocale(tag), q: Number.isFinite(q) ? q : 0, order };
|
|
53
|
+
}).filter(({ locale, q }) => locale && q > 0).sort((a, b) => b.q - a.q || a.order - b.order);
|
|
54
|
+
}
|
|
55
|
+
|
|
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
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
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 };
|
|
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 }> };
|
|
8
|
+
twitter: { card: "summary_large_image"; title: string; description: string; image: string };
|
|
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 }>;
|
|
14
|
+
export declare function renderSitemapXml(entries: ReturnType<typeof buildSitemap>): string;
|
|
15
|
+
export type RobotsPolicy = "allow" | "disallow";
|
|
16
|
+
export declare const ROBOTS_USER_AGENTS: { readonly citation: readonly string[]; readonly modelTraining: readonly string[] };
|
|
17
|
+
export declare function buildRobots(input: { baseUrl: string; environment: "production" | "preview" | "development"; policies?: { search: RobotsPolicy; citation: RobotsPolicy; modelTraining: RobotsPolicy } }): string;
|
package/src/core/seo.mjs
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { LOCALES, canonicalLocale, localeAttributes } from "./locales.mjs";
|
|
2
|
+
|
|
3
|
+
const cleanBase = (baseUrl) => String(baseUrl).replace(/\/+$/, "");
|
|
4
|
+
const cleanPath = (path) => {
|
|
5
|
+
const value = `/${String(path || "/").replace(/^\/+|\/+$/g, "")}`;
|
|
6
|
+
return value === "/" ? "/" : value;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function localizedPath(path, locale, defaultLocale) {
|
|
10
|
+
const canonical = canonicalLocale(locale);
|
|
11
|
+
const fallback = canonicalLocale(defaultLocale);
|
|
12
|
+
if (!canonical || !fallback) throw new RangeError("localizedPath received an unsupported locale");
|
|
13
|
+
const normalized = cleanPath(path);
|
|
14
|
+
return canonical === fallback ? normalized : `/${canonical}${normalized === "/" ? "" : normalized}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function localizedUrl(baseUrl, path, locale, defaultLocale) {
|
|
18
|
+
return `${cleanBase(baseUrl)}${localizedPath(path, locale, defaultLocale)}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function buildMetadata({ baseUrl, path, locale, defaultLocale, title, description, siteName, image }) {
|
|
22
|
+
const canonicalLocaleCode = canonicalLocale(locale);
|
|
23
|
+
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) });
|
|
27
|
+
const imageUrl = new URL(image, `${cleanBase(baseUrl)}/`).href;
|
|
28
|
+
return {
|
|
29
|
+
html: localeAttributes(canonicalLocaleCode),
|
|
30
|
+
title,
|
|
31
|
+
description,
|
|
32
|
+
canonical,
|
|
33
|
+
alternates,
|
|
34
|
+
openGraph: {
|
|
35
|
+
type: "website",
|
|
36
|
+
locale: canonicalLocaleCode,
|
|
37
|
+
url: canonical,
|
|
38
|
+
siteName,
|
|
39
|
+
title,
|
|
40
|
+
description,
|
|
41
|
+
images: [{ url: imageUrl }],
|
|
42
|
+
},
|
|
43
|
+
twitter: { card: "summary_large_image", title, description, image: imageUrl },
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function buildSitemap({ baseUrl, routes, defaultLocale, lastModified }) {
|
|
48
|
+
return routes.flatMap((path) => LOCALES.map(({ code }) => ({
|
|
49
|
+
loc: localizedUrl(baseUrl, path, code, defaultLocale),
|
|
50
|
+
locale: code,
|
|
51
|
+
alternates: [
|
|
52
|
+
...LOCALES.map(({ code: alternate }) => ({ hreflang: alternate, href: localizedUrl(baseUrl, path, alternate, defaultLocale) })),
|
|
53
|
+
{ hreflang: "x-default", href: localizedUrl(baseUrl, path, defaultLocale, defaultLocale) },
|
|
54
|
+
],
|
|
55
|
+
...(lastModified ? { lastmod: lastModified } : {}),
|
|
56
|
+
})));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function renderSitemapXml(entries) {
|
|
60
|
+
const escape = (value) => String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
|
61
|
+
const urls = entries.map((entry) => ` <url>\n <loc>${escape(entry.loc)}</loc>${entry.lastmod ? `\n <lastmod>${escape(entry.lastmod)}</lastmod>` : ""}${entry.alternates.map((alternate) => `\n <xhtml:link rel="alternate" hreflang="${escape(alternate.hreflang)}" href="${escape(alternate.href)}" />`).join("")}\n </url>`).join("\n");
|
|
62
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">\n${urls}\n</urlset>\n`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const ROBOTS_USER_AGENTS = Object.freeze({
|
|
66
|
+
citation: Object.freeze(["OAI-SearchBot", "PerplexityBot"]),
|
|
67
|
+
modelTraining: Object.freeze(["GPTBot", "Google-Extended", "ClaudeBot"]),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const robotsDirective = (policy) => {
|
|
71
|
+
if (policy === "allow") return "Allow: /";
|
|
72
|
+
if (policy === "disallow") return "Disallow: /";
|
|
73
|
+
throw new RangeError(`Unsupported robots policy: ${policy}`);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export function buildRobots({ baseUrl, environment, policies }) {
|
|
77
|
+
if (policies) {
|
|
78
|
+
const groups = [
|
|
79
|
+
`User-agent: *\n${robotsDirective(policies.search)}`,
|
|
80
|
+
`${ROBOTS_USER_AGENTS.citation.map((agent) => `User-agent: ${agent}`).join("\n")}\n${robotsDirective(policies.citation)}`,
|
|
81
|
+
`${ROBOTS_USER_AGENTS.modelTraining.map((agent) => `User-agent: ${agent}`).join("\n")}\n${robotsDirective(policies.modelTraining)}`,
|
|
82
|
+
];
|
|
83
|
+
if (environment === "production") groups.push(`Sitemap: ${cleanBase(baseUrl)}/sitemap.xml`);
|
|
84
|
+
return `${groups.join("\n\n")}\n`;
|
|
85
|
+
}
|
|
86
|
+
if (environment !== "production") return "User-agent: *\nDisallow: /\n";
|
|
87
|
+
return `User-agent: *\nAllow: /\nSitemap: ${cleanBase(baseUrl)}/sitemap.xml\n`;
|
|
88
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
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 };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export function toTanStackHead(metadata) {
|
|
2
|
+
return {
|
|
3
|
+
meta: [
|
|
4
|
+
{ title: metadata.title },
|
|
5
|
+
{ name: "description", content: metadata.description },
|
|
6
|
+
{ property: "og:type", content: metadata.openGraph.type },
|
|
7
|
+
{ property: "og:locale", content: metadata.openGraph.locale },
|
|
8
|
+
{ property: "og:url", content: metadata.openGraph.url },
|
|
9
|
+
{ property: "og:site_name", content: metadata.openGraph.siteName },
|
|
10
|
+
{ property: "og:title", content: metadata.openGraph.title },
|
|
11
|
+
{ property: "og:description", content: metadata.openGraph.description },
|
|
12
|
+
{ property: "og:image", content: metadata.openGraph.images[0].url },
|
|
13
|
+
{ name: "twitter:card", content: metadata.twitter.card },
|
|
14
|
+
{ name: "twitter:title", content: metadata.twitter.title },
|
|
15
|
+
{ name: "twitter:description", content: metadata.twitter.description },
|
|
16
|
+
{ name: "twitter:image", content: metadata.twitter.image },
|
|
17
|
+
],
|
|
18
|
+
links: [
|
|
19
|
+
{ rel: "canonical", href: metadata.canonical },
|
|
20
|
+
...metadata.alternates.map(({ hreflang, href }) => ({ rel: "alternate", hreflang, href })),
|
|
21
|
+
],
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const toHtmlAttributes = (metadata) => ({ lang: metadata.html.lang, dir: metadata.html.dir });
|
package/styles.css
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
@import "@devslab/dds-solid/styles.css";
|
|
2
|
+
|
|
3
|
+
.dds-sr-only { position: absolute; inline-size: 1px; block-size: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
|
|
4
|
+
.dds-sr-only:focus { position: fixed; inset-block-start: var(--dds-space-8); inset-inline-start: var(--dds-space-8); z-index: 1000; inline-size: auto; block-size: auto; padding: var(--dds-space-8) var(--dds-space-12); margin: 0; overflow: visible; clip-path: none; white-space: normal; border: 2px solid currentColor; border-radius: var(--dds-radius-sm); background: var(--dds-color-bg-default); color: var(--dds-color-text-primary); }
|
|
5
|
+
|
|
6
|
+
.site-shell { min-block-size: 100dvh; display: flex; flex-direction: column; background: var(--dds-color-bg-default); color: var(--dds-color-text-primary); }
|
|
7
|
+
.site-main { flex: 1; inline-size: min(100% - var(--dds-space-32), 1120px); margin-inline: auto; }
|
|
8
|
+
.site-header { position: sticky; inset-block-start: 0; z-index: 100; border-block-end: 1px solid var(--dds-color-border-default); background: var(--dds-color-bg-default); }
|
|
9
|
+
.site-header__inner { min-block-size: 64px; inline-size: min(100% - var(--dds-space-32), 1120px); margin-inline: auto; display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; gap: var(--dds-space-16); }
|
|
10
|
+
.site-brand { justify-self: start; color: var(--dds-color-text-primary); font-weight: 700; text-decoration: none; }
|
|
11
|
+
.site-nav { grid-column: 2; justify-self: center; min-inline-size: 0; }
|
|
12
|
+
.site-nav__list, .site-footer__links { display: flex; flex-wrap: wrap; gap: var(--dds-space-16); padding: 0; margin: 0; list-style: none; }
|
|
13
|
+
.site-nav__list a, .site-footer__links a { color: var(--dds-color-text-secondary); text-decoration: none; }
|
|
14
|
+
.site-nav__list a:hover, .site-footer__links a:hover { color: var(--dds-color-text-primary); text-decoration: underline; }
|
|
15
|
+
.site-header__controls { grid-column: 3; justify-self: end; display: flex; align-items: center; gap: var(--dds-space-8); }
|
|
16
|
+
.site-menu-button { display: none; }
|
|
17
|
+
.site-locale { inline-size: auto; min-inline-size: 9rem; }
|
|
18
|
+
.site-footer { border-block-start: 1px solid var(--dds-color-border-default); margin-block-start: var(--dds-space-64); padding-block: var(--dds-space-32); }
|
|
19
|
+
.site-footer__inner { inline-size: min(100% - var(--dds-space-32), 1120px); margin-inline: auto; display: grid; gap: var(--dds-space-16); }
|
|
20
|
+
.site-legal { max-inline-size: 760px; margin-inline: auto; padding-block: var(--dds-space-40); }
|
|
21
|
+
.site-status { border: 1px solid var(--dds-color-border-default); border-inline-start-width: var(--dds-space-4); border-radius: var(--dds-radius-md); padding: var(--dds-space-12) var(--dds-space-16); }
|
|
22
|
+
.site-status--danger { border-inline-start-color: var(--dds-color-status-danger); }
|
|
23
|
+
.site-status--warning { border-inline-start-color: var(--dds-color-status-warning); }
|
|
24
|
+
.site-status--success { border-inline-start-color: var(--dds-color-status-success); }
|
|
25
|
+
.site-status--info { border-inline-start-color: var(--dds-color-status-info); }
|
|
26
|
+
.site-request-form { display: grid; gap: var(--dds-space-16); max-inline-size: 640px; }
|
|
27
|
+
.site-centered-layout { min-block-size: 60dvh; display: grid; place-content: center; justify-items: start; gap: var(--dds-space-16); }
|
|
28
|
+
.oss-product-mark { display: block; flex: none; inline-size: var(--oss-product-mark-size); block-size: var(--oss-product-mark-size); object-fit: contain; }
|
|
29
|
+
.oss-product-mark--sm { --oss-product-mark-size: 24px; }
|
|
30
|
+
.oss-product-mark--md { --oss-product-mark-size: 32px; }
|
|
31
|
+
.oss-product-mark--lg { --oss-product-mark-size: 48px; }
|
|
32
|
+
|
|
33
|
+
@media (max-width: 720px) {
|
|
34
|
+
.site-header__inner { grid-template-columns: minmax(0, 1fr) auto; padding-block: var(--dds-space-12); }
|
|
35
|
+
.site-menu-button { display: inline-flex; grid-column: 2; grid-row: 1; }
|
|
36
|
+
.site-nav { display: none; grid-column: 1 / -1; grid-row: 2; justify-self: stretch; }
|
|
37
|
+
.site-nav[data-open="true"] { display: grid; }
|
|
38
|
+
.site-nav__list { display: grid; }
|
|
39
|
+
.site-header__controls { display: none; grid-column: 1 / -1; grid-row: 3; flex-wrap: wrap; }
|
|
40
|
+
.site-header__controls[data-open="true"] { display: flex; }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
@media (prefers-reduced-motion: reduce) { .site-header { scroll-behavior: auto; } }
|
|
44
|
+
@media (forced-colors: active) { .site-header, .site-footer, .site-status { forced-color-adjust: auto; } }
|