@duffcloudservices/cms 0.12.0 → 0.13.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.
- package/README.md +244 -8
- package/dist/chunk-A5F4C72F.js +500 -0
- package/dist/chunk-A5F4C72F.js.map +1 -0
- package/dist/{chunk-F3EIWEZD.js → chunk-HVSF23P7.js} +971 -73
- package/dist/chunk-HVSF23P7.js.map +1 -0
- package/dist/editor/editorBridge.d.ts +13 -1
- package/dist/editor/editorBridge.js +75 -5
- package/dist/editor/editorBridge.js.map +1 -1
- package/dist/headHonesty-OzxvLuwd.d.ts +222 -0
- package/dist/index.d.ts +365 -22
- package/dist/index.js +421 -21
- package/dist/index.js.map +1 -1
- package/dist/installSeoHead-kWQwObez.d.ts +627 -0
- package/dist/plugins/index.d.ts +90 -6
- package/dist/plugins/index.js +530 -49
- package/dist/plugins/index.js.map +1 -1
- package/dist/seo/index.d.ts +763 -4
- package/dist/seo/index.js +2 -2
- package/dist/{vitepressTransform-DfmABXmK.d.ts → vitepressTransform-JG_zlaux.d.ts} +99 -6
- package/package.json +17 -6
- package/src/components/DcsCallButton.test.ts +58 -0
- package/src/components/DcsCallButton.vue +19 -4
- package/src/components/LiteMediaEmbed.vue +3 -3
- package/src/components/ManagedImage.test.ts +34 -0
- package/src/components/ManagedImage.vue +5 -0
- package/src/components/PreviewRibbon.vue +25 -6
- package/src/components/raw-source-imports.test.ts +44 -0
- package/src/composables/useConversionTracking.test.ts +492 -0
- package/src/composables/useConversionTracking.ts +770 -0
- package/src/composables/useReleaseNotes.ts +7 -1
- package/src/composables/useSEO.applyHead.test.ts +150 -0
- package/src/composables/useSEO.ts +63 -17
- package/src/composables/useSiteVersion.ts +4 -1
- package/src/composables/useSiteVisitorSession.test.ts +56 -0
- package/src/composables/useSiteVisitorSession.ts +39 -3
- package/src/composables/useTextContent.ts +9 -1
- package/dist/chunk-DAYLLSEE.js +0 -3
- package/dist/chunk-DAYLLSEE.js.map +0 -1
- package/dist/chunk-F3EIWEZD.js.map +0 -1
- 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 };
|