@duffcloudservices/cms 0.12.0 → 0.13.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.
Files changed (39) hide show
  1. package/README.md +244 -8
  2. package/dist/chunk-A5F4C72F.js +500 -0
  3. package/dist/chunk-A5F4C72F.js.map +1 -0
  4. package/dist/{chunk-F3EIWEZD.js → chunk-HVSF23P7.js} +971 -73
  5. package/dist/chunk-HVSF23P7.js.map +1 -0
  6. package/dist/editor/editorBridge.d.ts +13 -1
  7. package/dist/editor/editorBridge.js +75 -5
  8. package/dist/editor/editorBridge.js.map +1 -1
  9. package/dist/headHonesty-OzxvLuwd.d.ts +222 -0
  10. package/dist/index.d.ts +365 -22
  11. package/dist/index.js +421 -21
  12. package/dist/index.js.map +1 -1
  13. package/dist/installSeoHead-kWQwObez.d.ts +627 -0
  14. package/dist/plugins/index.d.ts +90 -6
  15. package/dist/plugins/index.js +530 -49
  16. package/dist/plugins/index.js.map +1 -1
  17. package/dist/seo/index.d.ts +763 -4
  18. package/dist/seo/index.js +2 -2
  19. package/dist/{vitepressTransform-DfmABXmK.d.ts → vitepressTransform-JG_zlaux.d.ts} +99 -6
  20. package/package.json +17 -6
  21. package/src/components/DcsCallButton.test.ts +58 -0
  22. package/src/components/DcsCallButton.vue +19 -4
  23. package/src/components/LiteMediaEmbed.vue +3 -3
  24. package/src/components/ManagedImage.test.ts +34 -0
  25. package/src/components/ManagedImage.vue +5 -0
  26. package/src/components/PreviewRibbon.vue +4 -1
  27. package/src/composables/useConversionTracking.test.ts +492 -0
  28. package/src/composables/useConversionTracking.ts +770 -0
  29. package/src/composables/useReleaseNotes.ts +7 -1
  30. package/src/composables/useSEO.applyHead.test.ts +150 -0
  31. package/src/composables/useSEO.ts +63 -17
  32. package/src/composables/useSiteVersion.ts +4 -1
  33. package/src/composables/useSiteVisitorSession.test.ts +56 -0
  34. package/src/composables/useSiteVisitorSession.ts +39 -3
  35. package/src/composables/useTextContent.ts +9 -1
  36. package/dist/chunk-DAYLLSEE.js +0 -3
  37. package/dist/chunk-DAYLLSEE.js.map +0 -1
  38. package/dist/chunk-F3EIWEZD.js.map +0 -1
  39. package/dist/spliceHeadHtml-CsBEucGy.d.ts +0 -254
@@ -0,0 +1,627 @@
1
+ import { H as SeoConfiguration, x as BreadcrumbCrumb, z as ReviewSource, F as FaqSource, y as BlogMeta, T as ResolvedPageSeo, N as SeoSchemaConfig, G as GlobalSeoConfig, L as SeoOpenGraphConfig, M as SeoTwitterConfig } from './vitepressTransform-JG_zlaux.js';
2
+
3
+ /**
4
+ * Framework-agnostic SEO head-tag resolution.
5
+ *
6
+ * This module is the single source of truth for turning a
7
+ * (`pageSlug`, `pagePath`, `SeoConfiguration`) triple into a plain,
8
+ * serialisable description of the `<head>` tags a page should carry:
9
+ * resolved title, meta[], link[], and JSON-LD script[].
10
+ *
11
+ * It is consumed by:
12
+ * - `useSEO` (runtime, via `@unhead/vue`) — see `../composables/useSEO.ts`
13
+ * - `dcsSeoPlugin`'s build-time static-HTML emitter — see
14
+ * `../plugins/dcsSeoPlugin.ts`
15
+ *
16
+ * Keeping the resolution here (rather than inside the Vue composable) means
17
+ * the runtime and the build-time emitter produce byte-identical tags from the
18
+ * same `seo.yaml`, with no Vue/unhead dependency required at build time.
19
+ *
20
+ * Runtime behaviour is intentionally identical to the previous in-composable
21
+ * logic **except** for one corrected bug: `og:title` now falls back to the
22
+ * fully-resolved (template-applied) page title instead of the raw, untemplated
23
+ * `page.title`. Previously `og:title` could diverge from the `<title>` element
24
+ * (e.g. `<title>Iron Oak Contractors | Our Services</title>` but
25
+ * `og:title = "Our Services"`).
26
+ */
27
+
28
+ /** A `<meta>` tag — either a `name=`/`content=` or `property=`/`content=` pair. */
29
+ interface HeadMetaTag {
30
+ name?: string;
31
+ property?: string;
32
+ content: string;
33
+ }
34
+ /** A `<link>` tag (canonical, alternate, etc.). */
35
+ interface HeadLinkTag {
36
+ rel: string;
37
+ href: string;
38
+ hreflang?: string;
39
+ }
40
+ /** A `<script type="application/ld+json">` tag carrying serialised JSON-LD. */
41
+ interface HeadScriptTag {
42
+ type: string;
43
+ /** Pre-serialised JSON-LD string (already `JSON.stringify`-ed). */
44
+ children: string;
45
+ }
46
+ /**
47
+ * The complete, framework-agnostic set of resolved `<head>` tags for a page.
48
+ *
49
+ * - `title` is the final, template-applied title (what goes in `<title>`).
50
+ * - `meta` covers description, keywords, robots, verification, OG, Twitter.
51
+ * - `link` covers canonical + hreflang alternates.
52
+ * - `script` covers JSON-LD (global + page schemas).
53
+ * - `jsonLd` is the same JSON-LD as parsed objects, for callers that want the
54
+ * structured form (e.g. `useSEO().getSchema()`).
55
+ */
56
+ interface ResolvedHeadTags {
57
+ title: string;
58
+ meta: HeadMetaTag[];
59
+ link: HeadLinkTag[];
60
+ script: HeadScriptTag[];
61
+ jsonLd: object[];
62
+ /** The fully-resolved page SEO (merged global + page) used to build tags. */
63
+ resolved: ResolvedPageSeo;
64
+ }
65
+ /** Optional overrides applied on top of the resolved config when building tags. */
66
+ interface HeadTagOverrides {
67
+ /** Override the resolved `<title>`. */
68
+ title?: string;
69
+ /**
70
+ * Page-specific title fallback used when `seo.yaml` has no `title` for this
71
+ * page (e.g. the route `title` from `pages.yaml`). Unlike `global.defaultTitle`
72
+ * this IS run through `titleTemplate`, so un-configured routes (blog posts,
73
+ * etc.) get unique titles rather than the global default.
74
+ */
75
+ fallbackTitle?: string;
76
+ /** Override the resolved meta description. */
77
+ description?: string;
78
+ /** Override the meta keywords value. */
79
+ keywords?: string;
80
+ /**
81
+ * Force the robots directive (e.g. `'noindex, nofollow'`). When supplied this
82
+ * wins over both page- and global-level robots.
83
+ */
84
+ robots?: string;
85
+ /** Replace the JSON-LD schema objects entirely (already-built objects). */
86
+ schemas?: object[];
87
+ /** Extra meta tags appended after the generated ones. */
88
+ meta?: HeadMetaTag[];
89
+ /**
90
+ * Emit a `<meta name="keywords">` tag from the page's `keywords` field.
91
+ *
92
+ * Defaults to `false` so the `useSEO` runtime path stays byte-identical to
93
+ * its historical output (which never emitted keywords). The static-HTML
94
+ * emitter opts in (`true`) to surface page keywords in the baked `<head>`.
95
+ */
96
+ includeKeywords?: boolean;
97
+ /**
98
+ * Emit the global `@graph` spine (Organization + WebSite + the promoted
99
+ * LocalBusiness node, cross-linked by `@id`) PREPENDED before the per-schema
100
+ * JSON-LD. When true, the LocalBusiness subtype already present in
101
+ * `global.schemas` is ABSORBED into the graph (not emitted a second time).
102
+ * Default `false`.
103
+ */
104
+ emitGraph?: boolean;
105
+ /**
106
+ * Ordered Home → … → current breadcrumb trail (absolute item URLs). When it
107
+ * has more than one hop a `BreadcrumbList` is emitted; the home page (≤ 1 hop)
108
+ * emits none.
109
+ */
110
+ breadcrumbTrail?: BreadcrumbCrumb[];
111
+ /**
112
+ * REAL review items (from `.dcs/content.yaml`) for the LocalBusiness node.
113
+ * Honest Review[] + aggregateRating are emitted ONLY for items with a numeric
114
+ * rating + non-empty text + authorName; empty/missing ⇒ nothing.
115
+ */
116
+ reviews?: ReviewSource[];
117
+ /**
118
+ * Free-text trade/occupational license (from the `business.license`
119
+ * content.yaml key). When non-empty a `hasCredential`
120
+ * (`EducationalOccupationalCredential`, `credentialCategory: "license"`) is
121
+ * added to the LocalBusiness node in the `@graph`; empty/missing ⇒ nothing.
122
+ * Only consulted when `emitGraph` is true.
123
+ */
124
+ license?: string;
125
+ /**
126
+ * Structured FAQ pairs (frontmatter `faq:` / `.dcs/faq.yaml`). A `FAQPage` is
127
+ * emitted ONLY when at least one entry has a non-empty question AND answer.
128
+ */
129
+ faq?: FaqSource[];
130
+ /**
131
+ * Blog-post metadata. When `headline` is present a `BlogPosting` is emitted —
132
+ * UNLESS the page's own schemas already hand-author a `BlogPosting` (then the
133
+ * builder defers to the authored copy to avoid duplication).
134
+ */
135
+ blogMeta?: BlogMeta;
136
+ }
137
+ /**
138
+ * Generate Open Graph meta tags from config.
139
+ *
140
+ * @param resolvedTitle - the final, template-applied page title. Used as the
141
+ * `og:title` fallback so OG stays consistent with `<title>`.
142
+ */
143
+ declare function generateOpenGraphMeta(og: SeoOpenGraphConfig, global: GlobalSeoConfig, resolvedTitle: string, pageDescription: string, canonical: string): Array<{
144
+ property: string;
145
+ content: string;
146
+ }>;
147
+ /**
148
+ * Generate Twitter Card meta tags from config.
149
+ *
150
+ * @param resolvedTitle - the final, template-applied page title (Twitter title
151
+ * fallback), mirroring the OG behaviour.
152
+ */
153
+ declare function generateTwitterMeta(twitter: SeoTwitterConfig, global: GlobalSeoConfig, resolvedTitle: string, pageDescription: string): Array<{
154
+ name: string;
155
+ content: string;
156
+ }>;
157
+ /**
158
+ * Generate JSON-LD schema objects from schema configs, auto-populating common
159
+ * WebSite properties from global config.
160
+ */
161
+ declare function generateJsonLd(schemas: SeoSchemaConfig[], global: GlobalSeoConfig): object[];
162
+ /**
163
+ * Resolve page SEO by merging global defaults with page-specific config.
164
+ *
165
+ * Behavioural changes from the historical in-composable version (both bug
166
+ * fixes):
167
+ * 1. `openGraph.title` falls back to the **template-applied** page title, not
168
+ * the raw `page.title`, so `og:title` matches `<title>`.
169
+ * 2. The `titleTemplate` is applied **only** to a page-specific title
170
+ * (`page.title`, or the `fallbackTitle` arg). It is no longer applied to
171
+ * `global.defaultTitle`, which is already the complete brand title —
172
+ * templating it produced `"Brand | Default Title | Brand"` doubling on any
173
+ * page without its own `seo.yaml` entry.
174
+ *
175
+ * @param fallbackTitle - a page-specific title to use when `seo.yaml` has no
176
+ * `title` for this page (e.g. the route `title` from `pages.yaml`). It IS run
177
+ * through `titleTemplate`; `global.defaultTitle` is the last resort and is not.
178
+ */
179
+ declare function resolvePageSeo(pageSlug: string, pagePath: string | undefined, seoConfig: SeoConfiguration | undefined, fallbackTitle?: string): ResolvedPageSeo;
180
+ /**
181
+ * Build the complete, framework-agnostic set of `<head>` tags for a page.
182
+ *
183
+ * This is the function both the `useSEO` runtime and the build-time emitter
184
+ * call, guaranteeing identical output. Pass `overrides` to mirror the
185
+ * composable's `applyHead(overrides)` behaviour, or to force `robots` (used by
186
+ * the emitter's `noindex` option).
187
+ */
188
+ declare function buildHeadTags(pageSlug: string, pagePath: string | undefined, seoConfig: SeoConfiguration | undefined, overrides?: HeadTagOverrides): ResolvedHeadTags;
189
+
190
+ /**
191
+ * Pure, framework-free `<head>` splicing for the build-time SEO emitter.
192
+ *
193
+ * Given a built `index.html` shell and a set of resolved head tags (from
194
+ * `buildHeadTags`), this produces a new HTML string where the SEO-managed
195
+ * tags — `<title>`, `description`, `keywords`, `robots`, `canonical`,
196
+ * verification, all `og:*` / `article:*` properties, all `twitter:*` names,
197
+ * and `application/ld+json` scripts — have been **replaced** (not duplicated)
198
+ * with the resolved set.
199
+ *
200
+ * Design goals:
201
+ * - **Idempotent**: running it twice yields the same output (it strips the
202
+ * managed tags first, then re-inserts the canonical set).
203
+ * - **Deterministic**: tag order is fixed by `renderHeadTags`.
204
+ * - **Conservative**: only tags we own are touched. Charset, viewport, CSP,
205
+ * theme-color, favicons, stylesheets, and the app script are left intact.
206
+ *
207
+ * This is intentionally regex-based (no DOM dependency) to mirror the existing
208
+ * `dcsCdnImagePlugin` post-build HTML rewriting and to keep the emitter free of
209
+ * heavy parser deps at build time.
210
+ */
211
+
212
+ /**
213
+ * Escape a JSON-LD string for safe inclusion inside an HTML `<script>` element.
214
+ *
215
+ * A `<script type="application/ld+json">` is a DATA block, but the HTML parser
216
+ * still scans its raw text for `</script` (and `<!--`) — so untrusted CMS free
217
+ * text (review bodies, FAQ Q&A, blog descriptions) carrying `</script>` could
218
+ * break out of the script and inject markup (stored XSS).
219
+ *
220
+ * Per OWASP "JSON in an HTML context", we escape the three HTML-significant
221
+ * characters as their `\uXXXX` JSON escapes. Because these characters only ever
222
+ * appear INSIDE JSON string literals (never as JSON structure), the result is
223
+ * still byte-for-byte valid JSON that `JSON.parse` round-trips:
224
+ * `<` → `<` (defeats `</script>` and `<!--` breakout)
225
+ * `>` → `>`
226
+ * `&` → `&`
227
+ *
228
+ * Exported so BOTH static-SEO sinks (the SPA `spliceHeadHtml` path AND the
229
+ * VitePress `transformPageData` head emit) share one guard.
230
+ */
231
+ declare function escapeJsonLd(json: string): string;
232
+ /**
233
+ * Render the resolved head tags to a deterministic HTML fragment.
234
+ * Order: title, meta (in the order produced by buildHeadTags), link, script.
235
+ */
236
+ declare function renderHeadTags(tags: ResolvedHeadTags, indent?: string): string;
237
+ /**
238
+ * Strip the SEO-managed tags from a `<head>` block so they can be re-inserted
239
+ * without duplication. Operates only within `<head>...</head>` to avoid
240
+ * touching body content.
241
+ */
242
+ declare function stripManagedHeadTags(html: string): string;
243
+ /**
244
+ * Splice resolved SEO head tags into an HTML document.
245
+ *
246
+ * Strips the existing managed tags, then inserts the rendered canonical set
247
+ * immediately before `</head>`. If no `<head>` is present the HTML is returned
248
+ * unchanged (defensive — the emitter logs and no-ops in that case).
249
+ *
250
+ * Idempotent: applying twice produces identical output.
251
+ */
252
+ declare function spliceHeadHtml(html: string, tags: ResolvedHeadTags): string;
253
+
254
+ /**
255
+ * ROUTER-LEVEL HEAD RE-ASSERT (C-357 clause 4 — the COVERAGE half of the
256
+ * head-authority contract).
257
+ *
258
+ * WHAT PROBLEM THIS CLOSES. C-356 proved there is exactly ONE WRITER of the
259
+ * managed head fields (`.dcs/seo.yaml`). It did NOT prove every route re-asserts
260
+ * it. A view that writes no head at all is "silent": on a hard navigation it is
261
+ * CORRECT (the baked `<head>` of the fetched document is the approved one and
262
+ * nothing overwrites it), but on the first in-app click it leaves the PREVIOUS
263
+ * route's title/description/canonical in the DOM. Measured on boogie-babies:
264
+ * `/pricing` -> `/parties` still claims `canonical=/pricing`. The audit reports
265
+ * this as `uncovered-route` and deliberately does not fail on it, because the
266
+ * fix is one router hook in the platform, not eleven views remembering.
267
+ *
268
+ * WHY THIS FILE IS FULL OF REFUSALS. Turning silence from "safe" into "asserts
269
+ * whatever the lookup returns" is a LOADED CHANGE: on every route whose baked
270
+ * head is already correct, a wrong lookup is a live regression on a paying
271
+ * customer. A fleet sweep on 2026-07-28 measured three ways the obvious
272
+ * implementation breaks, and each one is encoded here as a hard rule.
273
+ *
274
+ * R1 DRIVE THE LOOKUP FROM `.dcs/pages.yaml`, NEVER FROM `to.meta.pageSlug`.
275
+ * KEPT carries `meta.pageSlug` on every route, which makes the meta-driven
276
+ * version look like the obvious choice. It is a trap: KEPT's two DYNAMIC
277
+ * routes carry the PLACEHOLDER keys `blog-post` and `topic`, and neither
278
+ * key exists in its `seo.yaml`. A meta-driven build therefore collapses
279
+ * 21 blog posts + 3 topic pages to `global.defaultTitle` ON THE TOP PAYER.
280
+ * The pages.yaml path->slug map resolves all 24 correctly, because it is
281
+ * the SAME map the build-time emitter walked to bake them.
282
+ * There is no read of `meta` anywhere in this file. That is the fix.
283
+ *
284
+ * R2 NO MATCH => TOUCH NOTHING. Not `global.defaultTitle`, not a clear, not
285
+ * a "best effort" — nothing. boogie-babies' catch-all is
286
+ * `/:pathMatch(.*)*` and its runtime path is the URL the visitor
287
+ * MISTYPED (`/nope-xyz`), never the pages.yaml path `/404`; that repo
288
+ * carries no `meta.pageSlug` at all. So a no-match is the NORMAL case
289
+ * there, and any fallback write would clobber a correct baked head on
290
+ * every 404 the site serves. No `seo.yaml` entry can fix it — the lookup
291
+ * KEY is the wrong thing, not the value.
292
+ *
293
+ * R3 NORMALIZE TRAILING SLASHES ON BOTH SIDES. KEPT declares `/blog/` in
294
+ * pages.yaml and `/blog` in router.ts. Naive path equality misses it and
295
+ * the blog index silently joins the no-match set. The root path `/` must
296
+ * NOT normalize to the empty string. A manifest that declares BOTH forms
297
+ * is rejected at BUILD time (see `findDuplicateNormalizedPaths`), because
298
+ * the emitter's last-write wins the output file while a lookup map's
299
+ * first-write wins the runtime — a duplicate therefore guarantees
300
+ * divergence whichever side you pick, so neither side may pick.
301
+ *
302
+ * R4 A FAILED NAVIGATION IS NOT A NAVIGATION. Vue Router calls
303
+ * `afterEach(to, from, failure)` for ABORTED, CANCELLED and DUPLICATED
304
+ * navigations, and when it fails the visible route stays `from`. A hook
305
+ * that reads only `to` therefore asserts the head of a page the visitor is
306
+ * NOT on: an auth guard rejecting `/pricing -> /account` leaves them on
307
+ * pricing under the ACCOUNT head, `noindex` included. Verified at source
308
+ * on vue-router 4.6.3 (`triggerAfterEach(toLocation, from, failure)` runs
309
+ * unconditionally; `finalizeNavigation` — the only assignment to
310
+ * `currentRoute` — is skipped when a failure is present) and empirically:
311
+ * a `beforeEach` returning `false` produced `afterEach(to=/account,
312
+ * from=/pricing, failure.type=4)` with `currentRoute` still `/pricing`.
313
+ * So: `failure` truthy => write NOTHING. This rule exists because the
314
+ * obvious version of THIS FIX invents its own divergence class.
315
+ *
316
+ * R5 THE INITIAL DOCUMENT IS NOT RE-ASSERTED. Its head is the baked,
317
+ * owner-approved one and is correct exactly once — on that document.
318
+ * Re-asserting buys nothing and puts the one head that is already right on
319
+ * the same code path as the one that can be wrong. This is ENFORCED, not
320
+ * assumed: `afterEach` alone does not guarantee it, because the generated
321
+ * app entry installs the router BEFORE awaiting `router.isReady()`
322
+ * (`packages/create-dcs-site/templates/vue-spa/src/main.ts`), so a hook
323
+ * registered on the adoption line can and does observe the initial
324
+ * navigation. The guard recognises vue-router's START location
325
+ * structurally (`name` unset, `matched` empty, path `/`) and skips at most
326
+ * ONE write, ever.
327
+ *
328
+ * MEASURED DEFECT F-A — **OPEN, AND IT BLOCKS FLEET ADOPTION** (C-362).
329
+ * The production-shaped fixture (`installSeoHead.integration.test.ts`) put a
330
+ * REAL Unhead client (both 1.11.20 and 2.0.19) on a REAL baked document and
331
+ * counted `document.head`. Two facts came out of it:
332
+ *
333
+ * GOOD — Unhead RECONCILES against the baked static head. It does not add
334
+ * alongside it. `<title>`, description, canonical, robots, og:* and twitter:*
335
+ * all stayed at EXACTLY ONE element through six navigations, with the new
336
+ * route's value. The feared "two titles, two canonicals" outcome does not
337
+ * happen on either version, and is in fact unreachable: Unhead dedupes by tag
338
+ * identity even across separate entries.
339
+ *
340
+ * BAD — Unhead only REMOVES side effects it OWNS, and it never owned the
341
+ * baked tags. So a managed tag that is present in the BAKED head and ABSENT
342
+ * from the runtime entry is left stranded in the DOM. Concretely: land on
343
+ * baked `/blog/post-a` (which bakes `<meta name="keywords">`), click through
344
+ * to `/blog/post-b` (whose seo.yaml declares none) and post A's keywords are
345
+ * still there while title, description and canonical have all moved. This is
346
+ * C-361 F4 — the divergence `includeKeywords: true` was added to close —
347
+ * surviving in its baked->runtime half. The class is EVERY OPTIONAL managed
348
+ * tag, not just keywords: the fixture reproduces it for `link[rel=alternate]`
349
+ * hreflang too, and `robots`/`article:*` are exposed the same way.
350
+ *
351
+ * It is invisible to the 610 fake-driven tests by construction: when the tag
352
+ * was written by a PREVIOUS re-assert, Unhead owns it and drops it correctly,
353
+ * which is all a recording fake can ever model.
354
+ *
355
+ * NO WORKAROUND IS APPLIED HERE ON PURPOSE. Every candidate fix — sweep the
356
+ * baked managed set once on first match, always emit every optional tag, or
357
+ * stop baking optional tags — changes the adoption contract, so this is an
358
+ * owner decision and not a patch. The fixture PINS the current behaviour with
359
+ * assertions named `DEFECT F-A`; flipping those two assertions is the
360
+ * acceptance test for whichever fix is chosen.
361
+ *
362
+ * WHAT IT RE-ASSERTS, AND WHAT IT DELIBERATELY DOES NOT.
363
+ * - `title`, `meta`, `link` — the managed fields P1 (`headHonesty.ts`) grades
364
+ * and the ones that go stale on SPA navigation. `meta[name=keywords]` is
365
+ * among them: the emitter passes `includeKeywords: true`, so a re-assert
366
+ * that omitted it would leave post A's baked keywords in the DOM on
367
+ * post A -> post B while every other managed field moved (C-361 F4).
368
+ * - NOT `script` (JSON-LD). The baked graph is built with inputs that do not
369
+ * exist at runtime (`content.yaml` reviews, business license, FAQ pairs,
370
+ * breadcrumb trails). Re-asserting a POORER graph would not correct the
371
+ * baked one, it would add a second, conflicting set of nodes — a new
372
+ * divergence class invented by the fix. Leaving it alone is the same
373
+ * judgement as R2: do not overwrite what you cannot faithfully reproduce.
374
+ * Named residual: after an in-app navigation the JSON-LD still describes the
375
+ * first document. Crawlers read the fetched document, where it is correct.
376
+ *
377
+ * IT RESOLVES THROUGH THE SHIPPED EMITTER (`buildHeadTags`) AND CARRIES THE TWO
378
+ * BUILD-TIME-ONLY INPUTS. A second head-building code path would be its own
379
+ * divergence source, so this calls the exact function the build calls. Two
380
+ * inputs the plain runtime composable does NOT carry are threaded through here
381
+ * because C-357 measured both as live divergences:
382
+ * - `fallbackTitle` (the pages.yaml route title). The emitter resolves
383
+ * `page.title || <route title>`; the runtime had no fallback and collapsed
384
+ * to `global.defaultTitle`. mi-handyman had FOUR such routes.
385
+ * - the `noindex` force. `dcsSeoPlugin({ noindex: [...] })` is a BUILD-TIME
386
+ * force; the runtime `__DCS_SEO__` global is the raw seo.yaml, so a re-assert
387
+ * without it republishes `index, follow` over a baked `noindex` — measured
388
+ * live on iron-oak `/account` and `/projects`. Re-asserting without carrying
389
+ * the list would ship that known regression by construction.
390
+ *
391
+ * NO vue-router IMPORT. The router is accepted structurally (anything with an
392
+ * `afterEach`), so this package gains no dependency and the tests need no router
393
+ * instance to prove the rules.
394
+ */
395
+
396
+ /**
397
+ * What this module reads off a resolved route.
398
+ *
399
+ * `path` is the ONLY field consulted for the lookup. `name` and `matched` are
400
+ * read off `from` ALONE, and only to recognise vue-router's START location for
401
+ * R5; nothing about the head is ever derived from them.
402
+ */
403
+ interface SeoHeadRouteLike {
404
+ path: string;
405
+ /** `from` only, R5: START location has no name. Never used for the lookup. */
406
+ name?: unknown;
407
+ /** `from` only, R5: START location matched nothing. Never used for the lookup. */
408
+ matched?: readonly unknown[];
409
+ }
410
+ /**
411
+ * The slice of a vue-router instance this needs.
412
+ *
413
+ * The hook takes the FULL vue-router `afterEach` arity. `failure` is not
414
+ * optional decoration — see R4: dropping it is what makes an aborted navigation
415
+ * assert the head of a page nobody is on.
416
+ *
417
+ * NOTE THE ABSENCE: there is no `meta` on {@link SeoHeadRouteLike}, and that
418
+ * absence is PINNED at compile time by {@link SEO_HEAD_ROUTE_HAS_NO_META}
419
+ * below rather than left as friction.
420
+ */
421
+ interface SeoHeadRouterLike {
422
+ afterEach(hook: (to: SeoHeadRouteLike, from?: SeoHeadRouteLike, failure?: unknown) => void): unknown;
423
+ }
424
+ /**
425
+ * COMPILE-TIME PIN: the route type this module accepts must carry NO `meta`.
426
+ *
427
+ * WHY A PIN AND NOT JUST AN ABSENCE. The absent property was described as
428
+ * "friction, not a gate", and the cross-model review proved it: a future edit
429
+ * adding `meta.pageSlug` as a NO-MATCH FALLBACK keeps every existing test green,
430
+ * because the matched-route tests supply POISONED meta (so a fallback never
431
+ * fires there) and the no-match tests supply NONE (so a fallback finds nothing).
432
+ * Widening the interface is the mechanical prerequisite for reading `meta`, so
433
+ * this is where R1 gets a real gate.
434
+ *
435
+ * Modelled on `APPLY_HEAD_TAKES_NO_ARGUMENTS` in `headContract.ts`, and it lives
436
+ * in a CHECKED SOURCE FILE for the same reason: `packages/cms/tsconfig.json`
437
+ * excludes `**\/*.test.ts`, so a `@ts-expect-error` in the test file would be a
438
+ * comment wearing a gate's clothes. Add `meta` to {@link SeoHeadRouteLike} and
439
+ * the assignment below stops compiling — red in this package's `type-check`,
440
+ * and through the published `.d.ts` in every site that runs one.
441
+ *
442
+ * HONEST LIMIT, stated rather than implied: this pins the TYPE SURFACE. It
443
+ * cannot stop `(to as any).meta`, exactly as `APPLY_HEAD_TAKES_NO_ARGUMENTS`
444
+ * cannot stop an `as any` argument. The behavioural half is the no-match test
445
+ * that navigates with a poisoned meta pointing at a REAL seo.yaml slug — the
446
+ * case the review found missing.
447
+ */
448
+ type SeoHeadRouteHasNoMeta = 'meta' extends keyof SeoHeadRouteLike ? false : true;
449
+ declare const SEO_HEAD_ROUTE_HAS_NO_META: SeoHeadRouteHasNoMeta;
450
+ /** A live unhead entry (`head.push(...)` result) — only `patch` is used. */
451
+ interface SeoHeadEntryLike {
452
+ patch(input: SeoHeadInput): void;
453
+ }
454
+ /** The unhead client (`createHead()`), passed in explicitly — see below. */
455
+ interface SeoHeadClientLike {
456
+ push(input: SeoHeadInput, options?: Record<string, unknown>): SeoHeadEntryLike;
457
+ }
458
+ /** The head payload handed to unhead. Managed fields only. */
459
+ interface SeoHeadInput {
460
+ title: string;
461
+ meta: HeadMetaTag[];
462
+ link: HeadLinkTag[];
463
+ }
464
+ /** One route from `.dcs/pages.yaml`, as the emitter reads it. */
465
+ interface SeoHeadPageRoute {
466
+ slug: string;
467
+ path: string;
468
+ title?: string;
469
+ }
470
+ /**
471
+ * The runtime manifest injected by `dcsSeoPlugin` as `__DCS_PAGES__`.
472
+ *
473
+ * It carries the `noindex` list alongside the routes because that list is a
474
+ * plugin OPTION, not a file — without it here the runtime cannot know that a
475
+ * route was baked `noindex, nofollow`.
476
+ */
477
+ interface SeoHeadPagesManifest {
478
+ routes: SeoHeadPageRoute[];
479
+ noindex?: string[];
480
+ }
481
+ /** Why a navigation did or did not result in a head write. */
482
+ type SeoHeadResolutionReason =
483
+ /** Matched a manifest route; the head was re-asserted. */
484
+ 'applied'
485
+ /** No manifest reached the runtime — the module is inert (never a fallback). */
486
+ | 'no-manifest'
487
+ /** The path is not a manifest route — R2: the baked head is left alone. */
488
+ | 'no-match'
489
+ /**
490
+ * R4 — the navigation FAILED (aborted / cancelled / duplicated), so the
491
+ * visitor is still on `from` and `to` is a page they never reached. Reported
492
+ * rather than silent: "the guard rejected it" and "the hook never ran" must
493
+ * not look the same from the outside.
494
+ */
495
+ | 'navigation-failed'
496
+ /**
497
+ * R5 — the initial document. Its baked head is the approved one; the hook
498
+ * observed the navigation (installation raced `router.isReady()`) and
499
+ * deliberately declined to write.
500
+ */
501
+ | 'initial-navigation';
502
+ interface SeoHeadResolution {
503
+ /** The path as the router reported it. */
504
+ path: string;
505
+ /** The path after R3 normalization — what the lookup actually used. */
506
+ normalizedPath: string;
507
+ matched: SeoHeadPageRoute | null;
508
+ reason: SeoHeadResolutionReason;
509
+ /** The resolved `<title>`, or `null` when nothing was written. */
510
+ title: string | null;
511
+ }
512
+ interface InstallSeoHeadOptions {
513
+ /**
514
+ * The unhead client the app already created (`const head = createHead()`).
515
+ *
516
+ * REQUIRED, and it cannot be discovered instead. `injectHead()` calls Vue's
517
+ * `inject`, which throws outside a component `setup()` — and a router
518
+ * `afterEach` hook is exactly that. Passing it is the honest API; guessing at
519
+ * a global would fail at runtime on the first navigation.
520
+ */
521
+ head: SeoHeadClientLike;
522
+ /** Route manifest. Defaults to the injected `__DCS_PAGES__.routes`. */
523
+ pages?: SeoHeadPageRoute[];
524
+ /** SEO config. Defaults to the injected `__DCS_SEO__`. */
525
+ seo?: SeoConfiguration;
526
+ /** Build-time noindex list. Defaults to the injected `__DCS_PAGES__.noindex`. */
527
+ noindex?: string[];
528
+ /**
529
+ * Observability hook, called once per navigation with the resolution —
530
+ * INCLUDING the no-match case. Exists so "it did nothing" is distinguishable
531
+ * from "it never ran", which is the vacuous-green shape this estate keeps
532
+ * counting. Never affects behaviour.
533
+ */
534
+ onResolve?: (resolution: SeoHeadResolution) => void;
535
+ }
536
+ /**
537
+ * Normalize a path for comparison. Applied to BOTH sides — the manifest path and
538
+ * the router path — because normalizing only one side is how `/blog/` and
539
+ * `/blog` stayed unequal.
540
+ *
541
+ * - drops `?query` and `#hash` (a router path should not carry them; a caller's
542
+ * `fullPath` might, and silently no-matching on a query string would be a
543
+ * surprise no one would ever debug)
544
+ * - strips trailing slashes
545
+ * - PRESERVES the root: `/` and `''` both normalize to `/`, never to `''`.
546
+ * Collapsing root to the empty string makes the home route unmatchable, which
547
+ * is the highest-traffic route on every site in the fleet.
548
+ */
549
+ declare function normalizeSeoHeadPath(input: string): string;
550
+ /**
551
+ * Find manifest paths that COLLIDE after R3 normalization.
552
+ *
553
+ * WHY THIS IS A BUILD ERROR AND NOT A TIEBREAK (C-361 F6, verified). A manifest
554
+ * carrying both `/blog` and `/blog/` resolves OPPOSITE ways on the two sides of
555
+ * the contract:
556
+ *
557
+ * - the EMITTER walks the manifest in order and writes one file per route, so
558
+ * for colliding paths the LAST write is the one left on disk;
559
+ * - a lookup Map keyed on the normalized path takes the FIRST entry, because
560
+ * that is what "don't clobber" means for a map.
561
+ *
562
+ * Whichever side is "fixed", the other is then wrong — the duplicate does not
563
+ * merely risk divergence, it GUARANTEES it. There is no correct pick, so the
564
+ * build must refuse instead of picking. The two entries have different slugs and
565
+ * therefore different seo.yaml identities; only the site owner knows which page
566
+ * that URL is.
567
+ *
568
+ * @returns the normalized paths that appear more than once, each with the
569
+ * colliding raw entries, in first-seen order. Empty array = clean.
570
+ */
571
+ declare function findDuplicateNormalizedPaths(routes: readonly SeoHeadPageRoute[]): Array<{
572
+ normalizedPath: string;
573
+ routes: SeoHeadPageRoute[];
574
+ }>;
575
+ /** Human-readable duplicate report, shared by the build throw and the runtime log. */
576
+ declare function formatDuplicateNormalizedPaths(dupes: ReadonlyArray<{
577
+ normalizedPath: string;
578
+ routes: SeoHeadPageRoute[];
579
+ }>): string;
580
+ /**
581
+ * Build the normalized path -> route lookup.
582
+ *
583
+ * On a duplicate normalized path the FIRST entry wins. That is a LAST-RESORT
584
+ * DETERMINISM RULE, not the contract: duplicates are rejected at build time by
585
+ * `findDuplicateNormalizedPaths` (R3), and this branch only ever runs for a
586
+ * manifest that reached a browser without passing that gate. It is deliberately
587
+ * not "clever" — an arbitrary-but-stable pick is the least bad thing to do once
588
+ * the loud failure has already been missed.
589
+ */
590
+ declare function buildSeoHeadRouteMap(routes: readonly SeoHeadPageRoute[]): Map<string, SeoHeadPageRoute>;
591
+ /**
592
+ * Re-assert the `.dcs/seo.yaml` head on every in-app navigation.
593
+ *
594
+ * Adoption is one line at app entry, after the head client exists:
595
+ *
596
+ * ```ts
597
+ * const head = createHead()
598
+ * app.use(head)
599
+ * installSeoHead(router, { head })
600
+ * ```
601
+ *
602
+ * DOES NOT WRITE FOR THE INITIAL DOCUMENT (R5) — and that is now IMPLEMENTED
603
+ * rather than asserted. The previous version of this docstring claimed
604
+ * `afterEach` "is registered for SUBSEQUENT navigations only"; there was no such
605
+ * guard, and the claim is false whenever the hook is installed while the initial
606
+ * navigation is still in flight — which is precisely what the adoption snippet
607
+ * above does in the generated app entry, where `app.use(router)` runs BEFORE
608
+ * `router.isReady()` is awaited. The head of the document the browser actually
609
+ * fetched is the baked, owner-approved one and is correct exactly once, on that
610
+ * document; re-asserting it buys nothing and puts the one head that is already
611
+ * right on the same code path as the one that can be wrong. Installing AFTER
612
+ * `await router.isReady()` reaches the same outcome by a different route (the
613
+ * hook simply never sees that navigation) and is equally supported — the guard
614
+ * exists so the outcome does not depend on remembering which.
615
+ *
616
+ * NAMED RESIDUAL (R5, dev only): under `vite dev` there is no baked per-route
617
+ * `<head>` — the shell's is generic — so the skipped initial write leaves the
618
+ * generic head in place until the first in-app navigation. Production is
619
+ * unaffected because production HAS a baked head. The skip is reported through
620
+ * {@link InstallSeoHeadOptions.onResolve} as `initial-navigation`, so it is
621
+ * observable rather than mysterious.
622
+ *
623
+ * @returns the hook's own removal function, as vue-router's `afterEach` returns.
624
+ */
625
+ declare function installSeoHead(router: SeoHeadRouterLike, options: InstallSeoHeadOptions): unknown;
626
+
627
+ export { type SeoHeadRouteHasNoMeta as A, type HeadMetaTag as H, type InstallSeoHeadOptions as I, type ResolvedHeadTags as R, type SeoHeadRouterLike as S, generateOpenGraphMeta as a, buildHeadTags as b, generateTwitterMeta as c, renderHeadTags as d, stripManagedHeadTags as e, escapeJsonLd as f, generateJsonLd as g, buildSeoHeadRouteMap as h, installSeoHead as i, type HeadLinkTag as j, type HeadScriptTag as k, type HeadTagOverrides as l, type SeoHeadRouteLike as m, normalizeSeoHeadPath as n, type SeoHeadClientLike as o, type SeoHeadEntryLike as p, type SeoHeadInput as q, resolvePageSeo as r, spliceHeadHtml as s, type SeoHeadPageRoute as t, type SeoHeadPagesManifest as u, type SeoHeadResolution as v, type SeoHeadResolutionReason as w, findDuplicateNormalizedPaths as x, formatDuplicateNormalizedPaths as y, SEO_HEAD_ROUTE_HAS_NO_META as z };