@duffcloudservices/cms 0.3.17 → 0.5.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.
@@ -0,0 +1,471 @@
1
+ import * as vue from 'vue';
2
+
3
+ /**
4
+ * Types for .dcs/seo.yaml structure
5
+ * Matches contracts/generated/schemas/seo.json
6
+ */
7
+ /**
8
+ * Root structure of .dcs/seo.yaml
9
+ */
10
+ interface SeoConfiguration {
11
+ /** Schema version */
12
+ version: number;
13
+ /** ISO timestamp of last update */
14
+ lastUpdated?: string;
15
+ /** Email or identifier of who made the update */
16
+ updatedBy?: string;
17
+ /** Global/site-wide SEO defaults */
18
+ global?: GlobalSeoConfig;
19
+ /** Page-specific SEO configurations keyed by page slug */
20
+ pages?: Record<string, PageSeoConfig>;
21
+ }
22
+ /**
23
+ * Global/site-wide SEO configuration
24
+ */
25
+ interface GlobalSeoConfig {
26
+ /** Site name used in titles and structured data */
27
+ siteName?: string;
28
+ /** Base URL of the site (e.g., https://example.com) */
29
+ siteUrl?: string;
30
+ /** Locale for Open Graph (e.g., en_US) */
31
+ locale?: string;
32
+ /** Default page title */
33
+ defaultTitle?: string;
34
+ /** Default meta description */
35
+ defaultDescription?: string;
36
+ /** Title template with %s placeholder (e.g., "%s | Site Name") */
37
+ titleTemplate?: string;
38
+ /** Author information for structured data */
39
+ author?: SeoAuthorConfig;
40
+ /** Social media handles */
41
+ social?: SeoSocialConfig;
42
+ /** Default images for social sharing */
43
+ images?: SeoImagesConfig;
44
+ /** Default robots directive (e.g., "index, follow") */
45
+ robots?: string;
46
+ /** Global JSON-LD schemas (Organization, WebSite, etc.) */
47
+ schemas?: SeoSchemaConfig[];
48
+ /** Search engine verification codes */
49
+ verification?: SeoVerificationConfig;
50
+ }
51
+ /**
52
+ * Page-specific SEO configuration
53
+ */
54
+ interface PageSeoConfig {
55
+ /** Page title */
56
+ title?: string;
57
+ /** Meta description */
58
+ description?: string;
59
+ /** Meta keywords (comma-separated) */
60
+ keywords?: string;
61
+ /** Canonical URL */
62
+ canonical?: string;
63
+ /** Page-specific robots directive */
64
+ robots?: string;
65
+ /** Open Graph configuration */
66
+ openGraph?: SeoOpenGraphConfig;
67
+ /** Twitter Card configuration */
68
+ twitter?: SeoTwitterConfig;
69
+ /** Page-specific JSON-LD schemas */
70
+ schemas?: SeoSchemaConfig[];
71
+ /** Alternate language links */
72
+ alternates?: SeoAlternateConfig[];
73
+ /** If true, don't apply titleTemplate to this page */
74
+ noTitleTemplate?: boolean;
75
+ }
76
+ /**
77
+ * Author information for structured data
78
+ */
79
+ interface SeoAuthorConfig {
80
+ /** Author name */
81
+ name?: string;
82
+ /** Author email */
83
+ email?: string;
84
+ /** Author image URL */
85
+ image?: string;
86
+ /** Job title */
87
+ jobTitle?: string;
88
+ /** Social profile URLs */
89
+ sameAs?: string[];
90
+ }
91
+ /**
92
+ * Social media handles
93
+ */
94
+ interface SeoSocialConfig {
95
+ /** Twitter handle (without @) */
96
+ twitter?: string;
97
+ /** LinkedIn company or profile slug */
98
+ linkedin?: string;
99
+ /** GitHub username */
100
+ github?: string;
101
+ /** Facebook page name */
102
+ facebook?: string;
103
+ /** Instagram username */
104
+ instagram?: string;
105
+ /** YouTube channel */
106
+ youtube?: string;
107
+ }
108
+ /**
109
+ * Default images for social sharing
110
+ */
111
+ interface SeoImagesConfig {
112
+ /** Logo image URL */
113
+ logo?: string;
114
+ /** Default Open Graph image */
115
+ ogDefault?: string;
116
+ /** Default Twitter Card image */
117
+ twitterDefault?: string;
118
+ /** Favicon URL */
119
+ favicon?: string;
120
+ }
121
+ /**
122
+ * Open Graph meta configuration
123
+ */
124
+ interface SeoOpenGraphConfig {
125
+ /** OG title (defaults to page title) */
126
+ title?: string;
127
+ /** OG description (defaults to page description) */
128
+ description?: string;
129
+ /** OG image URL */
130
+ image?: string;
131
+ /** Alt text for OG image */
132
+ imageAlt?: string;
133
+ /** OG image width in pixels */
134
+ imageWidth?: number;
135
+ /** OG image height in pixels */
136
+ imageHeight?: number;
137
+ /** OG type */
138
+ type?: 'website' | 'article' | 'profile' | 'book' | 'music.song' | 'music.album' | 'video.movie' | 'video.episode' | 'video.tv_show' | 'video.other';
139
+ /** OG URL (defaults to canonical) */
140
+ url?: string;
141
+ /** Article published time (ISO 8601) */
142
+ publishedTime?: string;
143
+ /** Article modified time (ISO 8601) */
144
+ modifiedTime?: string;
145
+ /** Article author */
146
+ author?: string;
147
+ /** Article section/category */
148
+ section?: string;
149
+ /** Article tags */
150
+ tags?: string[];
151
+ }
152
+ /**
153
+ * Twitter Card configuration
154
+ */
155
+ interface SeoTwitterConfig {
156
+ /** Card type */
157
+ card?: 'summary' | 'summary_large_image' | 'app' | 'player';
158
+ /** Twitter title */
159
+ title?: string;
160
+ /** Twitter description */
161
+ description?: string;
162
+ /** Twitter image URL */
163
+ image?: string;
164
+ /** Alt text for Twitter image */
165
+ imageAlt?: string;
166
+ /** Site's Twitter handle (without @) */
167
+ site?: string;
168
+ /** Content creator's Twitter handle (without @) */
169
+ creator?: string;
170
+ }
171
+ /**
172
+ * JSON-LD schema configuration
173
+ */
174
+ interface SeoSchemaConfig {
175
+ /** Schema.org type (e.g., "WebSite", "Organization", "Article") */
176
+ type: string;
177
+ /** Schema properties */
178
+ properties?: Record<string, unknown>;
179
+ }
180
+ /**
181
+ * Alternate language link
182
+ */
183
+ interface SeoAlternateConfig {
184
+ /** Language code (e.g., "en", "es", "x-default") */
185
+ hreflang: string;
186
+ /** URL of alternate version */
187
+ href: string;
188
+ }
189
+ /**
190
+ * Search engine verification codes
191
+ */
192
+ interface SeoVerificationConfig {
193
+ /** Google Search Console verification code */
194
+ google?: string;
195
+ /** Bing Webmaster Tools verification code */
196
+ bing?: string;
197
+ /** DuckDuckGo verification (reserved for future use) */
198
+ duckduckgo?: string;
199
+ }
200
+ /**
201
+ * Resolved page SEO configuration (after merging global + page)
202
+ */
203
+ interface ResolvedPageSeo {
204
+ /** Final page title */
205
+ title: string;
206
+ /** Final meta description */
207
+ description: string;
208
+ /** Page keywords (comma-separated), if configured */
209
+ keywords?: string;
210
+ /** Final canonical URL */
211
+ canonical: string;
212
+ /** Final robots directive */
213
+ robots: string;
214
+ /** Merged Open Graph configuration */
215
+ openGraph: Required<Pick<SeoOpenGraphConfig, 'title' | 'description' | 'type'>> & SeoOpenGraphConfig;
216
+ /** Merged Twitter configuration */
217
+ twitter: Required<Pick<SeoTwitterConfig, 'card'>> & SeoTwitterConfig;
218
+ /** All schemas (global + page) */
219
+ schemas: SeoSchemaConfig[];
220
+ /** Alternate links */
221
+ alternates: SeoAlternateConfig[];
222
+ }
223
+ /**
224
+ * Configuration for useSEO composable
225
+ */
226
+ interface UseSeoConfig {
227
+ /** Page slug matching entry in seo.yaml */
228
+ pageSlug: string;
229
+ /** Optional page path for canonical URL generation */
230
+ pagePath?: string;
231
+ }
232
+ /**
233
+ * Return type of useSEO composable
234
+ */
235
+ interface UseSeoReturn {
236
+ /** Computed page SEO configuration */
237
+ config: vue.ComputedRef<ResolvedPageSeo>;
238
+ /** Apply all meta tags via useHead */
239
+ applyHead: (overrides?: HeadOverrides) => void;
240
+ /** Get JSON-LD schema objects for the page */
241
+ getSchema: () => object[];
242
+ /** Get canonical URL for the page */
243
+ getCanonical: () => string;
244
+ /** Whether SEO config was loaded from build-time */
245
+ hasBuildTimeSeo: boolean;
246
+ }
247
+ /**
248
+ * Overrides that can be passed to applyHead
249
+ */
250
+ interface HeadOverrides {
251
+ /** Override title */
252
+ title?: string;
253
+ /** Override description */
254
+ description?: string;
255
+ /** Override keywords meta tag */
256
+ keywords?: string;
257
+ /** Additional or replacement schemas */
258
+ schemas?: object[];
259
+ /** Additional meta tags */
260
+ meta?: Array<{
261
+ name?: string;
262
+ property?: string;
263
+ content: string;
264
+ }>;
265
+ }
266
+
267
+ /**
268
+ * Build-time SEO for VitePress static-site generation.
269
+ *
270
+ * VitePress 1.6 does **not** use `unhead`, so the runtime `useSEO`/`applyHead`
271
+ * composable is a no-op against the SSG HTML. The SSG-correct sink is the
272
+ * `transformPageData(pageData)` build hook: writing `<meta>`/`<link>`/JSON-LD
273
+ * into `pageData.frontmatter.head` (VitePress bakes those into the rendered
274
+ * `<head>`) and overwriting `pageData.title` / `pageData.description` (VitePress
275
+ * renders the `<title>` — via `titleTemplate` — and the `description` meta from
276
+ * those two fields).
277
+ *
278
+ * This factory generalises the bespoke `buildSeoHead`/`transformPageData` that
279
+ * shipped inline in a site's `.vitepress/config.ts`. It reuses the shared,
280
+ * framework-agnostic resolver (`resolvePageSeo`, `generateOpenGraphMeta`,
281
+ * `generateTwitterMeta`, `generateJsonLd`) for global + page meta / OG / Twitter
282
+ * / canonical and the global JSON-LD knowledge graph, and delegates **page-type
283
+ * JSON-LD** (e.g. Article / Place / CollectionPage / Service / FAQPage +
284
+ * BreadcrumbList) to a *pluggable* rule set the site supplies. None of the
285
+ * real-estate (or any other vertical's) schema logic lives in this package — it
286
+ * is all site CONFIG.
287
+ *
288
+ * It is the VitePress counterpart to the Vue-SPA per-route emitter in
289
+ * `dcsSeoPlugin({ emitStaticHtml: true })`; both produce identical global
290
+ * meta/OG/Twitter/JSON-LD from the same `seo.yaml` via the shared resolver.
291
+ *
292
+ * @example
293
+ * ```ts
294
+ * // docs/.vitepress/config.ts
295
+ * import { createSeoTransformPageData } from '@duffcloudservices/cms/plugins'
296
+ * import seoConfig from '../../.dcs/seo.yaml'
297
+ *
298
+ * export default defineConfig({
299
+ * transformPageData: createSeoTransformPageData({
300
+ * seoConfig,
301
+ * pageTypeRules: [
302
+ * { match: (ctx) => ctx.route.startsWith('/blogs/'), build: (ctx) => [ ... ] },
303
+ * // ...Place / CollectionPage / Service / FAQPage rules
304
+ * ],
305
+ * }),
306
+ * })
307
+ * ```
308
+ */
309
+
310
+ /**
311
+ * A VitePress `head` entry. Mirrors VitePress's `HeadConfig` without taking a
312
+ * dependency on the `vitepress` package (which is not a dependency of this
313
+ * library). The tuple forms are:
314
+ * ['meta', { name|property, content }]
315
+ * ['link', { rel, href, ... }]
316
+ * ['script', { type: 'application/ld+json' }, '<serialised json>']
317
+ */
318
+ type VitePressHeadConfig = [string, Record<string, string>] | [string, Record<string, string>, string];
319
+ /**
320
+ * The minimal slice of VitePress's `PageData` this factory reads and mutates.
321
+ * Typed structurally so callers can pass VitePress's real `PageData` without a
322
+ * cast and without this package importing `vitepress`.
323
+ */
324
+ interface VitePressPageData {
325
+ /** Source-relative path, e.g. `index.md`, `blogs/my-post.md`. */
326
+ relativePath: string;
327
+ /** Dynamic-route params (e.g. `{ topic: 'home-buying' }`). */
328
+ params?: Record<string, unknown>;
329
+ /** Page frontmatter; `head` is appended to here. */
330
+ frontmatter: Record<string, any>;
331
+ /** VitePress page title (drives `<title>` via `titleTemplate`). */
332
+ title?: string;
333
+ /** VitePress page description (drives the `description` meta). */
334
+ description?: string;
335
+ [key: string]: unknown;
336
+ }
337
+ /**
338
+ * Context handed to the site's page-type rules and resolver hooks. Everything a
339
+ * site needs to derive its title/description/og/schemas for one page, computed
340
+ * once per page by the factory.
341
+ */
342
+ interface SeoPageContext {
343
+ /** Route path, e.g. `/`, `/blogs/my-post`, `/locations/birmingham`. */
344
+ route: string;
345
+ /** Slug: `'home'` for `/`, otherwise the route without its leading slash. */
346
+ slug: string;
347
+ /** Absolute canonical URL for this route. */
348
+ canonical: string;
349
+ /** Normalised site base URL (no trailing slash), e.g. `https://example.com`. */
350
+ siteUrl: string;
351
+ /** The page's frontmatter (read-only convenience; same object as pageData). */
352
+ frontmatter: Record<string, any>;
353
+ /** The resolved global SEO config block. */
354
+ global: GlobalSeoConfig;
355
+ /** The full VitePress page data (for rules that need more than the above). */
356
+ pageData: VitePressPageData;
357
+ }
358
+ /**
359
+ * The resolved per-page title / description / OG type a site may override.
360
+ * Returned by the optional `resolvePage` hook so a site can apply its own
361
+ * per-page-type title precedence (e.g. "{City} Luxury Real Estate") and decide
362
+ * whether that title should win over VitePress's `titleTemplate`.
363
+ */
364
+ interface ResolvedPageOverrides {
365
+ /**
366
+ * The page title. When `setPageTitle` is true this is written to
367
+ * `pageData.title` (VitePress then applies `titleTemplate`).
368
+ */
369
+ title?: string;
370
+ /**
371
+ * When true, `title` is written back to `pageData.title`. Leave false for
372
+ * pages whose frontmatter title should remain authoritative (e.g. blog posts
373
+ * that already carry a good `<h1>`/title).
374
+ */
375
+ setPageTitle?: boolean;
376
+ /** The meta description. Written to `pageData.description` when truthy. */
377
+ description?: string;
378
+ /** Open Graph type override (e.g. `'article'`, `'profile'`). */
379
+ ogType?: SeoOpenGraphConfig['type'];
380
+ /** Open Graph image URL override (e.g. a post's header image). */
381
+ ogImage?: string;
382
+ /** Keywords override (comma-separated) for the `keywords` meta. */
383
+ keywords?: string;
384
+ /**
385
+ * Open Graph title override. Defaults to `title` so og:title tracks <title>.
386
+ */
387
+ ogTitle?: string;
388
+ /**
389
+ * Open Graph description override. Defaults to `description`.
390
+ */
391
+ ogDescription?: string;
392
+ }
393
+ /** A single pluggable page-type rule: when `match` is true, emit `build`. */
394
+ interface SeoPageTypeRule {
395
+ /** Return true when this rule applies to the page (by route/slug/etc.). */
396
+ match: (ctx: SeoPageContext) => boolean;
397
+ /** Build the page-type JSON-LD objects to emit (already plain objects). */
398
+ build: (ctx: SeoPageContext) => Array<Record<string, unknown>>;
399
+ }
400
+ interface CreateSeoTransformPageDataOptions {
401
+ /** The parsed `.dcs/seo.yaml` (global graph + per-page meta). */
402
+ seoConfig: SeoConfiguration | undefined;
403
+ /**
404
+ * Pluggable page-type rules. Evaluated in order; **every** matching rule's
405
+ * `build` output is emitted (so a route can contribute both a primary schema
406
+ * and a BreadcrumbList from one rule, or be matched by several). The
407
+ * real-estate BlogPosting / Place / CollectionPage / Service / FAQPage logic
408
+ * is supplied here by the site — never hardcoded in this package.
409
+ */
410
+ pageTypeRules?: SeoPageTypeRule[];
411
+ /**
412
+ * Optional hook to override per-page title / description / OG before tags are
413
+ * built — the site's title precedence and per-type description fallbacks.
414
+ * Receives the same context as the rules. Anything it omits falls back to the
415
+ * resolver / frontmatter defaults.
416
+ */
417
+ resolvePage?: (ctx: SeoPageContext) => ResolvedPageOverrides | undefined;
418
+ /**
419
+ * Map a `relativePath` (+ params) to a route. Defaults to a VitePress-correct
420
+ * implementation: `index` becomes `/`, a trailing `/index` is dropped, `.md`
421
+ * is stripped, and dynamic `[name]` segments are substituted from
422
+ * `pageData.params`. Override only for unusual routing.
423
+ */
424
+ relativePathToRoute?: (relativePath: string, params?: Record<string, unknown>) => string;
425
+ /**
426
+ * Emit a `<meta name="keywords">` from the resolved/overridden keywords.
427
+ * Default true (parity with the bespoke KDH emitter, which emitted keywords).
428
+ */
429
+ includeKeywords?: boolean;
430
+ /** Enable debug logging of the emitted head per page. */
431
+ debug?: boolean;
432
+ }
433
+ /** Default VitePress route derivation (matches the bespoke KDH helper). */
434
+ declare function defaultRelativePathToRoute(relativePath: string, params?: Record<string, unknown>): string;
435
+ /**
436
+ * Build just the SEO head tuples for a page (no `pageData` mutation). Exposed
437
+ * separately so it is unit-testable without a VitePress `pageData` round-trip
438
+ * and reusable by callers that manage the `head`/`title` sinks themselves.
439
+ *
440
+ * @returns `{ head, title, description }` — the head tuples to append, and the
441
+ * final title/description (already overridden) the caller should write to
442
+ * `pageData` when `applyTitle`/`applyDescription` are appropriate.
443
+ */
444
+ declare function buildVitePressSeoHead(pageData: VitePressPageData, options: CreateSeoTransformPageDataOptions): {
445
+ head: VitePressHeadConfig[];
446
+ title?: string;
447
+ description?: string;
448
+ setPageTitle: boolean;
449
+ };
450
+ /**
451
+ * Create a VitePress `transformPageData(pageData)` function that bakes DCS SEO
452
+ * (global meta/OG/Twitter/canonical + global JSON-LD graph + pluggable
453
+ * page-type JSON-LD) into the SSG `<head>`.
454
+ *
455
+ * Mutations performed on `pageData`:
456
+ * - **`frontmatter.head`** — the resolved tags are *appended* to any existing
457
+ * `head` (so site-level `head` config is preserved).
458
+ * - **`description`** — set to the resolved/overridden description so
459
+ * VitePress emits exactly one `description` meta (no duplicate; we do not
460
+ * push our own description meta).
461
+ * - **`title`** — set only when the site's `resolvePage` hook returns
462
+ * `setPageTitle: true` for this page, mirroring the bespoke behaviour where
463
+ * seo.yaml/per-type titles are authoritative but a post's frontmatter title
464
+ * is left intact.
465
+ *
466
+ * Defensive: never throws (a failure logs a warning and leaves `pageData`
467
+ * untouched), so SEO can never break a production VitePress build.
468
+ */
469
+ declare function createSeoTransformPageData(options: CreateSeoTransformPageDataOptions): (pageData: VitePressPageData) => void;
470
+
471
+ export { type CreateSeoTransformPageDataOptions as C, type GlobalSeoConfig as G, type HeadOverrides as H, type PageSeoConfig as P, type ResolvedPageSeo as R, type SeoConfiguration as S, type UseSeoReturn as U, type VitePressPageData as V, type SeoSchemaConfig as a, type SeoOpenGraphConfig as b, type SeoTwitterConfig as c, createSeoTransformPageData as d, buildVitePressSeoHead as e, defaultRelativePathToRoute as f, type SeoPageContext as g, type SeoPageTypeRule as h, type ResolvedPageOverrides as i, type VitePressHeadConfig as j, type SeoAuthorConfig as k, type SeoSocialConfig as l, type SeoImagesConfig as m, type SeoAlternateConfig as n, type SeoVerificationConfig as o, type UseSeoConfig as p };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@duffcloudservices/cms",
3
- "version": "0.3.17",
3
+ "version": "0.5.0",
4
4
  "description": "Vue 3 composables and Vite plugins for DCS CMS integration",
5
5
  "type": "module",
6
6
  "exports": {