@better-seo/core 0.0.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.
@@ -0,0 +1,322 @@
1
+ /** Strict JSON-LD values (no `any` on public surface). */
2
+ type JSONLDValue = string | number | boolean | null | JSONLDValue[] | {
3
+ readonly [k: string]: JSONLDValue;
4
+ };
5
+ /**
6
+ * JSON-LD node. Helpers must ensure `@context` and `@type` before serialization when required by consumers.
7
+ */
8
+ type JSONLD = {
9
+ readonly "@context"?: string | Record<string, unknown>;
10
+ readonly "@type": string;
11
+ readonly "@id"?: string;
12
+ } & {
13
+ readonly [k: string]: JSONLDValue | Record<string, unknown> | undefined;
14
+ };
15
+ interface SEOPlugin {
16
+ readonly id: string;
17
+ readonly beforeMerge?: (draft: SEOInput, ctx: {
18
+ readonly config?: SEOConfig;
19
+ }) => SEOInput;
20
+ readonly afterMerge?: (seo: SEO, ctx: {
21
+ readonly config?: SEOConfig;
22
+ }) => SEO;
23
+ }
24
+ interface SEOConfig {
25
+ readonly titleTemplate?: string;
26
+ readonly baseUrl?: string;
27
+ readonly defaultRobots?: string;
28
+ readonly schemaMerge?: "concat" | {
29
+ readonly dedupeByIdAndType?: boolean;
30
+ };
31
+ readonly features?: Partial<{
32
+ readonly jsonLd: boolean;
33
+ readonly openGraphMerge: boolean;
34
+ }>;
35
+ /** Hooks run in order; prefer `createSEOContext` for request-scoped registration (future hardening). */
36
+ readonly plugins?: readonly SEOPlugin[];
37
+ }
38
+ /** hreflang → absolute or path URL (adapter maps to framework expectations). */
39
+ interface SEOAlternates {
40
+ readonly languages?: Readonly<Record<string, string>>;
41
+ }
42
+ interface SEOMeta {
43
+ readonly title: string;
44
+ readonly description?: string;
45
+ readonly canonical?: string;
46
+ readonly robots?: string;
47
+ readonly alternates?: SEOAlternates;
48
+ }
49
+ interface SEOImage {
50
+ readonly url: string;
51
+ readonly width?: number;
52
+ readonly height?: number;
53
+ readonly alt?: string;
54
+ }
55
+ interface SEO {
56
+ readonly meta: SEOMeta;
57
+ readonly openGraph?: {
58
+ readonly title?: string;
59
+ readonly description?: string;
60
+ readonly url?: string;
61
+ readonly type?: string;
62
+ readonly images?: readonly SEOImage[];
63
+ };
64
+ readonly twitter?: {
65
+ readonly card?: "summary" | "summary_large_image";
66
+ readonly title?: string;
67
+ readonly description?: string;
68
+ readonly image?: string;
69
+ };
70
+ readonly schema: readonly JSONLD[];
71
+ }
72
+ type SEOAdapter<TOutput = unknown> = {
73
+ readonly id: string;
74
+ toFramework(seo: SEO): TOutput;
75
+ };
76
+ /** Loosely-accepted shape for `createSEO` / voilà entrypoints. */
77
+ type SEOInput = Omit<Partial<SEO>, "meta" | "schema" | "openGraph" | "twitter"> & {
78
+ readonly meta?: Partial<SEOMeta>;
79
+ readonly title?: string;
80
+ readonly description?: string;
81
+ readonly canonical?: string;
82
+ readonly robots?: string;
83
+ readonly openGraph?: SEO["openGraph"];
84
+ readonly twitter?: SEO["twitter"];
85
+ readonly schema?: readonly JSONLD[];
86
+ };
87
+ /** Rule output merges into `Partial<SEOInput>` before `createSEO`. */
88
+ interface SEORule {
89
+ readonly match: string;
90
+ readonly priority?: number;
91
+ readonly seo: Partial<SEOInput>;
92
+ }
93
+ type TagDescriptor = {
94
+ readonly kind: "meta";
95
+ readonly name?: string;
96
+ readonly property?: string;
97
+ readonly content: string;
98
+ } | {
99
+ readonly kind: "link";
100
+ readonly rel: string;
101
+ readonly href: string;
102
+ readonly hreflang?: string;
103
+ } | {
104
+ readonly kind: "script-jsonld";
105
+ readonly json: string;
106
+ };
107
+
108
+ /** Stable codes for programmatic handling (enterprise / observability). */
109
+ type SEOErrorCode = "VALIDATION" | "ADAPTER_NOT_FOUND" | "MIGRATE_NOT_IMPLEMENTED" | "USE_SEO_NOT_AVAILABLE";
110
+ declare class SEOError extends Error {
111
+ readonly code: SEOErrorCode;
112
+ readonly cause?: unknown;
113
+ constructor(code: SEOErrorCode, message?: string, options?: {
114
+ cause?: unknown;
115
+ });
116
+ }
117
+ declare function isSEOError(e: unknown): e is SEOError;
118
+
119
+ /** Normalize partial + config into a canonical `SEO` document (Wave 1 baseline). */
120
+ declare function createSEO(input: SEOInput, config?: SEOConfig): SEO;
121
+ /** Merge parent `SEO` with child input (featured as `withSEO` in docs / Next package). */
122
+ declare function withSEO(parent: SEO, child: SEOInput, config?: SEOConfig): SEO;
123
+ declare function mergeSEO(parent: SEO, child: SEOInput, config?: SEOConfig): SEO;
124
+
125
+ /** WebPage helper — ensures `@context` + `@type`. */
126
+ declare function webPage(parts: {
127
+ readonly name: string;
128
+ readonly description?: string;
129
+ readonly url: string;
130
+ }): JSONLD;
131
+ declare function article(parts: {
132
+ readonly headline: string;
133
+ readonly description?: string;
134
+ readonly datePublished?: string;
135
+ readonly url: string;
136
+ }): JSONLD;
137
+ declare function organization(parts: {
138
+ readonly name: string;
139
+ readonly url?: string;
140
+ readonly logo?: string;
141
+ }): JSONLD;
142
+ declare function person(parts: {
143
+ readonly name: string;
144
+ readonly url?: string;
145
+ }): JSONLD;
146
+ declare function product(parts: {
147
+ readonly name: string;
148
+ readonly description?: string;
149
+ readonly sku?: string;
150
+ readonly image?: string;
151
+ readonly url: string;
152
+ }): JSONLD;
153
+ declare function breadcrumbList(parts: {
154
+ readonly items: ReadonlyArray<{
155
+ readonly name: string;
156
+ readonly url: string;
157
+ }>;
158
+ }): JSONLD;
159
+ declare function faqPage(parts: {
160
+ readonly questions: ReadonlyArray<{
161
+ readonly question: string;
162
+ readonly answer: string;
163
+ }>;
164
+ }): JSONLD;
165
+ /** Technical / how-to article (docs templates, PRD §2.5). */
166
+ declare function techArticle(parts: {
167
+ readonly headline: string;
168
+ readonly description?: string;
169
+ readonly datePublished?: string;
170
+ readonly url: string;
171
+ }): JSONLD;
172
+ /** Escape hatch for custom `@type` graphs (caller owns validity). */
173
+ declare function customSchema(node: JSONLD): JSONLD;
174
+
175
+ /**
176
+ * Single JSON-LD serialization path — `JSON.stringify` on the whole graph only (ARCHITECTURE §7).
177
+ * Includes validation to prevent prototype pollution and XSS attacks.
178
+ * For multiple nodes, callers may pass an array; consumers typically emit one script tag per call.
179
+ *
180
+ * @param data - JSON-LD node or array of nodes to serialize
181
+ * @returns JSON string suitable for embedding in <script type="application/ld+json">
182
+ * @throws {SEOError} If validation fails
183
+ *
184
+ * @security Validates against prototype pollution, ensures @context is schema.org,
185
+ * and properly escapes all user content via JSON.stringify
186
+ */
187
+ declare function serializeJSONLD(data: JSONLD | readonly JSONLD[]): string;
188
+
189
+ /** Vanilla tag list for snapshots and non-framework hosts (ARCHITECTURE §8). */
190
+ declare function renderTags(seo: SEO): TagDescriptor[];
191
+
192
+ type ValidationSeverity = "warning" | "error";
193
+ /** Stable machine-readable codes (PRD §3.5 / observability). */
194
+ type ValidationIssueCode = "TITLE_EMPTY" | "TITLE_TOO_LONG" | "DESCRIPTION_MISSING" | "DESCRIPTION_REQUIRED" | "DESCRIPTION_TOO_LONG" | "OG_IMAGE_NARROW" | "SCHEMA_MISSING_TYPE";
195
+ interface ValidationIssue {
196
+ readonly code: ValidationIssueCode;
197
+ readonly field: string;
198
+ readonly message: string;
199
+ readonly severity: ValidationSeverity;
200
+ }
201
+ interface ValidateSEOOptions {
202
+ /** When false, validation is a no-op (production / Edge bundles). */
203
+ readonly enabled?: boolean;
204
+ /** When true, missing description is `error` instead of `warning`. */
205
+ readonly requireDescription?: boolean;
206
+ /** In non-production only: when not false, log each issue to console.warn (default: true). */
207
+ readonly log?: boolean;
208
+ readonly titleMaxLength?: number;
209
+ readonly descriptionMaxLength?: number;
210
+ }
211
+ /**
212
+ * Development-oriented checks — no heavy deps (ARCHITECTURE §12).
213
+ * Returns structured issues; set `log: false` to consume programmatically without console noise.
214
+ */
215
+ declare function validateSEO(seo: SEO, options?: ValidateSEOOptions): readonly ValidationIssue[];
216
+
217
+ /**
218
+ * Register an adapter implementation.
219
+ *
220
+ * ⚠️ **SECURITY**: Only register adapters from trusted sources.
221
+ * Malicious adapters can intercept and modify SEO data.
222
+ *
223
+ * @param adapter - The adapter to register
224
+ * @throws {SEOError} If adapter ID is invalid or adapter is not an object
225
+ *
226
+ * @example
227
+ * ```ts
228
+ * import { registerAdapter } from '@better-seo/core'
229
+ *
230
+ * registerAdapter({
231
+ * id: 'my-framework',
232
+ * toFramework: (seo) => { /* conversion logic *\/ }
233
+ * })
234
+ * ```
235
+ */
236
+ declare function registerAdapter<T>(adapter: SEOAdapter<T>): void;
237
+ declare function getAdapter<T = unknown>(id: string): SEOAdapter<T> | undefined;
238
+ declare function listAdapterIds(): string[];
239
+
240
+ declare function defineSEOPlugin(plugin: SEOPlugin): SEOPlugin;
241
+
242
+ interface SEOContext {
243
+ readonly config: SEOConfig;
244
+ /** Request-scoped `createSEO` with bound config + plugins. */
245
+ readonly createSEO: (input: SEOInput) => SEO;
246
+ readonly mergeSEO: (parent: SEO, child: SEOInput) => SEO;
247
+ }
248
+ /**
249
+ * Preferred production pattern for multi-tenant / Edge — explicit config, no filesystem inference (ARCHITECTURE §13).
250
+ */
251
+ declare function createSEOContext(config: SEOConfig): SEOContext;
252
+
253
+ /**
254
+ * ⚠️ **SECURITY WARNING**: Global state is NOT safe for multi-tenant or serverless environments.
255
+ *
256
+ * This function stores config in module-level global state, which can leak between:
257
+ * - Different users in serverless functions (Vercel, Netlify, Cloudflare Workers)
258
+ * - Concurrent requests in Node.js servers
259
+ * - Different tenants in multi-tenant applications
260
+ *
261
+ * **DO NOT USE** in:
262
+ * - Server-side rendering (SSR) with multiple users
263
+ * - Edge functions or Workers
264
+ * - Multi-tenant SaaS applications
265
+ * - Any environment where config must be isolated per request
266
+ *
267
+ * **SAFE ALTERNATIVE:** Use `createSEOContext()` for request-scoped configuration.
268
+ *
269
+ * @deprecated Use `createSEOContext()` for production applications.
270
+ * Only suitable for single-user, static sites or development.
271
+ *
272
+ * @param config - Global SEO configuration to store
273
+ *
274
+ * @see {@link createSEOContext} for request-scoped configuration
275
+ * @see {@link internal-docs/ARCHITECTURE.md} §10 for runtime matrix
276
+ * @see {@link internal-docs/USAGE.md} for security best practices
277
+ */
278
+ declare function initSEO(config: SEOConfig): void;
279
+ /**
280
+ * Gets the global SEO config.
281
+ *
282
+ * @deprecated Use `createSEOContext()` for production applications.
283
+ *
284
+ * @returns The global SEO config, or undefined if not initialized
285
+ *
286
+ * @see {@link createSEOContext} for request-scoped configuration
287
+ */
288
+ declare function getGlobalSEOConfig(): SEOConfig | undefined;
289
+ /**
290
+ * Resets the global SEO config.
291
+ *
292
+ * @internal Used for testing only
293
+ */
294
+ declare function resetSEOConfigForTests(): void;
295
+
296
+ /**
297
+ * Resolve via registered adapter — prefer `@better-seo/next`’s `seo()` for the Next voilà path.
298
+ * @throws {SEOError} ADAPTER_NOT_FOUND when the adapter id was never registered
299
+ */
300
+ declare function seoForFramework<T>(adapterId: string, input: SEOInput, config?: SEOConfig): T;
301
+ /**
302
+ * Stub until `@better-seo/react` (Wave 5 / V3). Calling this makes missing peer support obvious in dev.
303
+ * @throws {SEOError} USE_SEO_NOT_AVAILABLE
304
+ */
305
+ declare function useSEO(): never;
306
+
307
+ /**
308
+ * Pure rule matcher — `**` segment globs + legacy `prefix*` path match (ARCHITECTURE §11 subset).
309
+ */
310
+ declare function applyRules(route: string, rules: readonly SEORule[]): Partial<SEOInput>;
311
+ /** Merge rule output into a base document (helper for adapters). */
312
+ declare function applyRulesToSEO(route: string, base: SEO, rules: readonly SEORule[], config?: SEOConfig): SEO;
313
+ /** Convenience: rules → `createSEO`. */
314
+ declare function createSEOForRoute(route: string, input: SEOInput, rules: readonly SEORule[], config?: SEOConfig): SEO;
315
+
316
+ /**
317
+ * Codemod-oriented helpers — stub until Wave 12 / CLI `migrate` (FEATURES C15).
318
+ * @throws {SEOError} MIGRATE_NOT_IMPLEMENTED
319
+ */
320
+ declare function fromNextSeo(_nextSeoExport: unknown): never;
321
+
322
+ export { type JSONLD, type JSONLDValue, type SEO, type SEOAdapter, type SEOAlternates, type SEOConfig, type SEOContext, SEOError, type SEOErrorCode, type SEOImage, type SEOInput, type SEOMeta, type SEOPlugin, type SEORule, type TagDescriptor, type ValidateSEOOptions, type ValidationIssue, type ValidationIssueCode, type ValidationSeverity, applyRules, applyRulesToSEO, article, breadcrumbList, createSEO, createSEOContext, createSEOForRoute, customSchema, defineSEOPlugin, faqPage, fromNextSeo, getAdapter, getGlobalSEOConfig, initSEO, isSEOError, listAdapterIds, mergeSEO, organization, person, product, registerAdapter, renderTags, resetSEOConfigForTests, seoForFramework, serializeJSONLD, techArticle, useSEO, validateSEO, webPage, withSEO };