@duffcloudservices/cms 0.11.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.
- 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 +53 -1
- package/dist/editor/editorBridge.js +141 -5
- package/dist/editor/editorBridge.js.map +1 -1
- package/dist/headHonesty-OzxvLuwd.d.ts +222 -0
- package/dist/index.d.ts +369 -22
- package/dist/index.js +424 -22
- package/dist/index.js.map +1 -1
- package/dist/installSeoHead-kWQwObez.d.ts +627 -0
- package/dist/plugins/index.d.ts +173 -6
- package/dist/plugins/index.js +628 -54
- 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 +26 -16
- package/src/components/DcsCallButton.test.ts +58 -0
- package/src/components/DcsCallButton.vue +19 -4
- package/src/components/DcsReviewShowcase.vue +5 -1
- package/src/components/LiteMediaEmbed.vue +3 -3
- package/src/components/ManagedImage.test.ts +94 -0
- package/src/components/ManagedImage.vue +58 -6
- package/src/components/PreviewRibbon.vue +4 -1
- 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/useResponsiveImage.ts +6 -0
- 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
package/dist/seo/index.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
export {
|
|
2
|
-
import {
|
|
3
|
-
export { A as AI_BOTS, y as BlogMeta, x as BreadcrumbCrumb, v as BuildLlmsParams, u as BuildRobotsParams, B as BuildSitemapParams, E as ContentConfig, C as CreateSeoTransformPageDataOptions, D as DcsRobotsOptions, F as FaqSource,
|
|
1
|
+
export { j as HeadLinkTag, H as HeadMetaTag, k as HeadScriptTag, l as HeadTagOverrides, I as InstallSeoHeadOptions, R as ResolvedHeadTags, z as SEO_HEAD_ROUTE_HAS_NO_META, o as SeoHeadClientLike, p as SeoHeadEntryLike, q as SeoHeadInput, t as SeoHeadPageRoute, u as SeoHeadPagesManifest, v as SeoHeadResolution, w as SeoHeadResolutionReason, A as SeoHeadRouteHasNoMeta, m as SeoHeadRouteLike, S as SeoHeadRouterLike, b as buildHeadTags, h as buildSeoHeadRouteMap, f as escapeJsonLd, x as findDuplicateNormalizedPaths, y as formatDuplicateNormalizedPaths, g as generateJsonLd, a as generateOpenGraphMeta, c as generateTwitterMeta, i as installSeoHead, n as normalizeSeoHeadPath, d as renderHeadTags, r as resolvePageSeo, s as spliceHeadHtml, e as stripManagedHeadTags } from '../installSeoHead-kWQwObez.js';
|
|
2
|
+
import { Z as PageRouteEntry } from '../vitepressTransform-JG_zlaux.js';
|
|
3
|
+
export { A as AI_BOTS, y as BlogMeta, x as BreadcrumbCrumb, v as BuildLlmsParams, u as BuildRobotsParams, B as BuildSitemapParams, E as ContentConfig, C as CreateSeoTransformPageDataOptions, D as DcsRobotsOptions, F as FaqSource, ac as NormalisedFaq, ab as NormalisedReview, R as ResolvedPageOverrides, ad as ReviewSchemaParts, z as ReviewSource, a2 as RouteEmissionRules, a3 as RouteExclusionReason, w as SchemaObject, S as SeoPageContext, r as SeoPageTypeRule, t as VitePressHeadConfig, V as VitePressPageData, q as absolutizeUrl, j as breadcrumbTrailFromRoute, k as buildBlogPosting, h as buildBreadcrumbList, l as buildFaqPage, g as buildGlobalGraph, a4 as buildHasCredential, f as buildLlmsTxt, m as buildReviewSchemaParts, e as buildRobotsTxt, a as buildSitemapXml, b as buildVitePressSeoHead, c as createSeoTransformPageData, d as defaultRelativePathToRoute, a8 as deriveSameAs, o as filterRealFaq, n as filterRealReviews, a7 as findBusinessLicense, a5 as findLocalBusinessSchema, p as findReviewItemsForPage, a6 as graphAbsorbs, a9 as graphIds, aa as isLocalBusinessType, a0 as isRouteEmitted, i as isRouteIndexable, _ as loadPagesManifest, $ as parsePagesManifest, a1 as routeExclusionReason, s as slugToTitle } from '../vitepressTransform-JG_zlaux.js';
|
|
4
|
+
import { a as HeadObservation } from '../headHonesty-OzxvLuwd.js';
|
|
5
|
+
export { B as BakedHead, g as HeadHonestyError, l as HeadHonestyExecution, k as HeadHonestyNotRunError, p as HeadHonestyOptions, o as HeadHonestyViolation, H as HonestyMode, m as HonestyModeInput, b as assertHeadHonesty, h as assertHeadHonestyExecuted, d as decodeHtmlEntities, e as extractBakedHead, i as findHeadHonestyExecutionFaults, f as findHeadHonestyViolations, j as formatHeadHonestyExecutionReport, c as formatHeadHonestyReport, n as normalizeHeadText, r as resolveHonestyMode } from '../headHonesty-OzxvLuwd.js';
|
|
4
6
|
import 'vue';
|
|
5
7
|
|
|
6
8
|
/**
|
|
@@ -44,10 +46,30 @@ import 'vue';
|
|
|
44
46
|
* head emitter import it without a plugin↔module cycle.
|
|
45
47
|
*/
|
|
46
48
|
declare function routeToOutputFile(outDir: string, routePath: string): string;
|
|
49
|
+
/**
|
|
50
|
+
* The `<head>` values the app left behind after it mounted — i.e. what a
|
|
51
|
+
* JS-rendering crawler (and every human) actually sees. Captured in the SAME
|
|
52
|
+
* render pass that produces the body, so the P1 head-honesty rail costs no extra
|
|
53
|
+
* page loads. See `headHonesty.ts` for what is done with it.
|
|
54
|
+
*/
|
|
55
|
+
interface RenderedHead {
|
|
56
|
+
/** `document.title` after mount. */
|
|
57
|
+
title: string | null;
|
|
58
|
+
/** The EFFECTIVE `meta[name=description]` (the last one in the DOM). */
|
|
59
|
+
description: string | null;
|
|
60
|
+
/** How many `meta[name=description]` tags exist — >1 is itself a smell. */
|
|
61
|
+
descriptionCount: number;
|
|
62
|
+
}
|
|
47
63
|
/** The rendered body of one route plus any fatal render errors observed. */
|
|
48
64
|
interface RenderedRoute {
|
|
49
65
|
/** The `innerHTML` of the app mount container (`#app`) after the app rendered. */
|
|
50
66
|
bodyHtml: string;
|
|
67
|
+
/**
|
|
68
|
+
* The post-mount `<head>` observations, when the renderer captures them. The
|
|
69
|
+
* fake renderers in unit tests may omit it; the production Playwright renderer
|
|
70
|
+
* always provides it.
|
|
71
|
+
*/
|
|
72
|
+
head?: RenderedHead;
|
|
51
73
|
/**
|
|
52
74
|
* FATAL render errors (uncaught exceptions / failed mount) that mean the body
|
|
53
75
|
* is untrustworthy. A non-empty list makes `prerenderBodies` throw a
|
|
@@ -105,8 +127,25 @@ interface PrerenderBodiesParams {
|
|
|
105
127
|
noindex?: string[];
|
|
106
128
|
/** `pages.yaml` top-level `excluded:` globs — matching routes are skipped. */
|
|
107
129
|
excludedGlobs?: string[];
|
|
130
|
+
/**
|
|
131
|
+
* Write the rendered body back into the per-route file (default `true`).
|
|
132
|
+
*
|
|
133
|
+
* Set `false` to run the pass purely as an OBSERVER: routes are still rendered
|
|
134
|
+
* and their `<head>` still captured for the P1 honesty rail, but `dist/` is not
|
|
135
|
+
* touched. That is what makes head honesty checkable on a site that has opted
|
|
136
|
+
* out of body prerender (`seo.yaml prerenderBody: false`) without giving it a
|
|
137
|
+
* second, duplicate browser pass.
|
|
138
|
+
*/
|
|
139
|
+
spliceBody?: boolean;
|
|
108
140
|
debug?: boolean;
|
|
109
141
|
}
|
|
142
|
+
/** Why a route never reached the renderer. */
|
|
143
|
+
type PrerenderFilterReason = 'excluded' | 'excluded-glob' | 'noindex' | 'no-head-file';
|
|
144
|
+
/** One route the pass never rendered, and why. */
|
|
145
|
+
interface FilteredRoute {
|
|
146
|
+
route: string;
|
|
147
|
+
reason: PrerenderFilterReason;
|
|
148
|
+
}
|
|
110
149
|
/** Outcome of a body-prerender pass. */
|
|
111
150
|
interface PrerenderBodiesResult {
|
|
112
151
|
/** Number of per-route files whose body was filled. */
|
|
@@ -115,6 +154,25 @@ interface PrerenderBodiesResult {
|
|
|
115
154
|
skipped: number;
|
|
116
155
|
/** The route paths that were prerendered, in order (for logging/tests). */
|
|
117
156
|
routes: string[];
|
|
157
|
+
/**
|
|
158
|
+
* Per-route baked-vs-rendered `<head>` observations, collected for every route
|
|
159
|
+
* this pass rendered (including ones whose body was skipped as empty). Consumed
|
|
160
|
+
* by the P1 rail; empty when the renderer does not report head values.
|
|
161
|
+
*/
|
|
162
|
+
headObservations: HeadObservation[];
|
|
163
|
+
/**
|
|
164
|
+
* Every route that actually reached `renderer.renderRoute` — i.e. the set the
|
|
165
|
+
* P1 head-honesty rail COULD have observed. This is the denominator that makes
|
|
166
|
+
* "the rail ran" checkable: `headObservations.length < eligibleRoutes.length`
|
|
167
|
+
* means some eligible route produced no observation, and an empty
|
|
168
|
+
* `eligibleRoutes` means the pass checked nothing at all (C-338).
|
|
169
|
+
*/
|
|
170
|
+
eligibleRoutes: string[];
|
|
171
|
+
/**
|
|
172
|
+
* Routes that never reached the renderer, each with the reason. Lets the
|
|
173
|
+
* caller name exactly what was NOT checked instead of reporting a bare zero.
|
|
174
|
+
*/
|
|
175
|
+
filteredRoutes: FilteredRoute[];
|
|
118
176
|
}
|
|
119
177
|
/**
|
|
120
178
|
* For each emitted route: render its body and splice it into the per-route
|
|
@@ -151,4 +209,705 @@ interface BodyPrerenderEnableInput {
|
|
|
151
209
|
*/
|
|
152
210
|
declare function isBodyPrerenderEnabled(input: BodyPrerenderEnableInput): boolean;
|
|
153
211
|
|
|
154
|
-
|
|
212
|
+
/**
|
|
213
|
+
* THE HEAD-AUTHORITY CONTRACT (C-356) — a source-level audit.
|
|
214
|
+
*
|
|
215
|
+
* WHY THIS EXISTS. One undefined contract produced two OPPOSITE production
|
|
216
|
+
* failures on the same shared composable, measured a day apart:
|
|
217
|
+
*
|
|
218
|
+
* KEPT 28 `applyHead({…})` callers OVERWROTE the baked head.
|
|
219
|
+
* 93 title/description divergences across 49 routes — 100% of
|
|
220
|
+
* routes served one <title> to a non-JS AI crawler and a
|
|
221
|
+
* different one to Google and every human (C-338 matrix,
|
|
222
|
+
* fixed C-341: 51/51, zero divergent).
|
|
223
|
+
* boogie-babies 2 callers, 9 of 11 views that write NOTHING. Hard navigation
|
|
224
|
+
* is correct; the first in-app click leaves the previous
|
|
225
|
+
* route's title/description/canonical in the DOM (measured:
|
|
226
|
+
* /pricing -> /parties still claims canonical /pricing).
|
|
227
|
+
* just-posh 17 raw `useHead({title, meta, link})` blocks that never touch
|
|
228
|
+
* the composable at all — 27 divergences, the fleet's second
|
|
229
|
+
* largest. A contract about `applyHead` alone would not see it.
|
|
230
|
+
*
|
|
231
|
+
* THE CONTRACT, in one line: `.dcs/seo.yaml` is the ONLY writer of the managed
|
|
232
|
+
* head fields, and it must be re-asserted on EVERY route, on EVERY navigation.
|
|
233
|
+
* Full text + reasoning: `.docs/plans/dynamic-site-resolution/README.md`
|
|
234
|
+
* § "The head-authority contract (C-356)".
|
|
235
|
+
*
|
|
236
|
+
* WHAT THIS MODULE PROVES AND WHAT IT DOES NOT.
|
|
237
|
+
* - It proves there is exactly ONE WRITER in the source. That is authority.
|
|
238
|
+
* - It does NOT prove the writer told the truth. That is P1
|
|
239
|
+
* (`headHonesty.ts`), which compares the baked head to the rendered head on
|
|
240
|
+
* a real build. Neither replaces the other, and an exemption here buys no
|
|
241
|
+
* amnesty there.
|
|
242
|
+
*
|
|
243
|
+
* Deliberately framework-free and fs-free: it takes source strings, so the same
|
|
244
|
+
* function runs in the package's own vitest fixtures, in a site's test script,
|
|
245
|
+
* and in `cli/head-contract-audit.mjs` against a real repo. There is no second
|
|
246
|
+
* implementation to drift.
|
|
247
|
+
*/
|
|
248
|
+
|
|
249
|
+
/** Codes that FAIL the contract. */
|
|
250
|
+
type HeadContractViolationCode =
|
|
251
|
+
/** `applyHead({…})` — a second writer for a managed field. Never exemptible. */
|
|
252
|
+
'override-argument'
|
|
253
|
+
/** `useSEO('x')` where `.dcs/seo.yaml` has no `pages.x.title`. Never exemptible. */
|
|
254
|
+
| 'undeclared-slug'
|
|
255
|
+
/** `useHead` / `useSeoMeta` / `document.title =` — a writer outside the factory. */
|
|
256
|
+
| 'foreign-head-writer'
|
|
257
|
+
/** An exemption whose file no longer violates anything (anti-rot). */
|
|
258
|
+
| 'stale-exemption'
|
|
259
|
+
/** An exemption with no real reason recorded. */
|
|
260
|
+
| 'empty-exemption-reason'
|
|
261
|
+
/** An exemption that names a host but cannot be checked against it (C-420). */
|
|
262
|
+
| 'unprobed-exemption'
|
|
263
|
+
/** A live probe contradicts what the exemption asserts about its host (C-420). */
|
|
264
|
+
| 'exemption-claim-refuted'
|
|
265
|
+
/** The probe of an exemption's host did not complete — never a pass (C-420). */
|
|
266
|
+
| 'exemption-probe-failed'
|
|
267
|
+
/** The audit scanned no files — a pass that checked nothing is not a pass. */
|
|
268
|
+
| 'scan-empty'
|
|
269
|
+
/** No `.dcs/seo.yaml` was supplied, so authority cannot be established. */
|
|
270
|
+
| 'no-seo-config';
|
|
271
|
+
/** Codes that are REPORTED but do not fail. */
|
|
272
|
+
type HeadContractWarningCode =
|
|
273
|
+
/** A view that never re-asserts the head; SPA navigation to it is stale. */
|
|
274
|
+
'uncovered-route'
|
|
275
|
+
/** `useSEO(`blog-${slug}`)` — legal, but only P1 can judge it. */
|
|
276
|
+
| 'dynamic-slug';
|
|
277
|
+
interface HeadContractSourceFile {
|
|
278
|
+
/** Path as the caller wants it reported (and as exemptions key on). */
|
|
279
|
+
path: string;
|
|
280
|
+
source: string;
|
|
281
|
+
}
|
|
282
|
+
/** The slice of `.dcs/seo.yaml` this audit needs. */
|
|
283
|
+
interface HeadContractSeoConfig {
|
|
284
|
+
global?: {
|
|
285
|
+
titleTemplate?: string;
|
|
286
|
+
defaultTitle?: string;
|
|
287
|
+
} & Record<string, unknown>;
|
|
288
|
+
pages?: Record<string, {
|
|
289
|
+
title?: string;
|
|
290
|
+
} & Record<string, unknown>>;
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* WHY EXEMPTIONS CARRY LIVE CLAIMS.
|
|
294
|
+
*
|
|
295
|
+
* An exemption used to be a sentence. A sentence is an assertion, and an
|
|
296
|
+
* assertion nobody re-measures rots into a false premise that the audit then
|
|
297
|
+
* defends: the measured case declared a host as a "noindex cross-domain
|
|
298
|
+
* handoff", and every part of that was false in production — the host served
|
|
299
|
+
* `index, follow`, a SELF-referential canonical, a 200 `robots.txt` that allows
|
|
300
|
+
* every crawler, a sitemap and a full independent site. The audit was green
|
|
301
|
+
* BECAUSE of the exemption, so the green was the bug.
|
|
302
|
+
*
|
|
303
|
+
* The discriminator is the exempted host's real response, not the bundle hash.
|
|
304
|
+
* So an exemption that NAMES a host must now say what it expects that host to
|
|
305
|
+
* do, and the caller must supply what the host actually did. A named host with
|
|
306
|
+
* nothing checkable, or with nothing measured, is a VIOLATION — not a skip.
|
|
307
|
+
*
|
|
308
|
+
* This module still never touches the network: it takes observations. The
|
|
309
|
+
* fetching lives in the runner (`cli/head-contract-audit.mjs`), the judging
|
|
310
|
+
* lives here, and `parseHostProbeHtml` below is the shared, testable parser
|
|
311
|
+
* between them so there is no second implementation to drift.
|
|
312
|
+
*/
|
|
313
|
+
interface HeadContractExemptionExpectation {
|
|
314
|
+
/** HTTP status, measured WITHOUT following redirects (a 301 is not a 200). */
|
|
315
|
+
status?: number;
|
|
316
|
+
/** Substring that must appear in `<meta name="robots">` (case-insensitive). */
|
|
317
|
+
robots?: string;
|
|
318
|
+
/** Hostname of `<link rel="canonical">` — the self-vs-cross-domain question. */
|
|
319
|
+
canonicalHost?: string;
|
|
320
|
+
/** Substring that must appear in `<title>` (case-insensitive). */
|
|
321
|
+
titleContains?: string;
|
|
322
|
+
}
|
|
323
|
+
interface HeadContractExemptionRecord {
|
|
324
|
+
/** Why this file cannot route its head through the factory. */
|
|
325
|
+
reason: string;
|
|
326
|
+
/**
|
|
327
|
+
* `host -> claims`. EVERY host the reason names must appear here, and every
|
|
328
|
+
* entry must carry at least one claim, or the exemption is unprobed.
|
|
329
|
+
*/
|
|
330
|
+
hosts?: Record<string, HeadContractExemptionExpectation>;
|
|
331
|
+
}
|
|
332
|
+
/** A bare string is still a legal exemption — it just may not name a host. */
|
|
333
|
+
type HeadContractExemptionValue = string | HeadContractExemptionRecord;
|
|
334
|
+
/** What a caller actually observed at a host. Never produced by this module. */
|
|
335
|
+
interface HeadContractHostProbe {
|
|
336
|
+
host: string;
|
|
337
|
+
/** `false` = the probe did not complete. A failed probe is never a pass. */
|
|
338
|
+
ok: boolean;
|
|
339
|
+
error?: string;
|
|
340
|
+
status?: number;
|
|
341
|
+
robots?: string | null;
|
|
342
|
+
canonical?: string | null;
|
|
343
|
+
title?: string | null;
|
|
344
|
+
observedAt?: string;
|
|
345
|
+
}
|
|
346
|
+
type HeadContractExemptionVerdict =
|
|
347
|
+
/** Every claim agreed with the live response. */
|
|
348
|
+
'live-true'
|
|
349
|
+
/** The live response contradicts at least one claim. */
|
|
350
|
+
| 'refuted'
|
|
351
|
+
/** Names a host but nothing checkable reached the judge. */
|
|
352
|
+
| 'unprobed'
|
|
353
|
+
/** A probe was attempted and did not complete. */
|
|
354
|
+
| 'probe-failed'
|
|
355
|
+
/** Asserts nothing about any host — nothing to probe. */
|
|
356
|
+
| 'no-host-claim';
|
|
357
|
+
interface HeadContractExemptionCheck {
|
|
358
|
+
host: string;
|
|
359
|
+
claim: keyof HeadContractExemptionExpectation;
|
|
360
|
+
expected: string;
|
|
361
|
+
actual: string;
|
|
362
|
+
pass: boolean;
|
|
363
|
+
}
|
|
364
|
+
interface HeadContractExemptionAudit {
|
|
365
|
+
file: string;
|
|
366
|
+
reason: string;
|
|
367
|
+
/** Hosts the PROSE names — the claim the author made whether or not they meant to. */
|
|
368
|
+
hostsNamedInReason: string[];
|
|
369
|
+
/** Hosts the author declared machine-checkable claims for. */
|
|
370
|
+
hostsDeclared: string[];
|
|
371
|
+
checks: HeadContractExemptionCheck[];
|
|
372
|
+
verdict: HeadContractExemptionVerdict;
|
|
373
|
+
}
|
|
374
|
+
interface HeadContractInput {
|
|
375
|
+
files: HeadContractSourceFile[];
|
|
376
|
+
seo: HeadContractSeoConfig | undefined;
|
|
377
|
+
/**
|
|
378
|
+
* `path -> reason | record`. Declares a KNOWN foreign head writer so it stops
|
|
379
|
+
* being a surprise. It is a declaration of debt, not a licence: P1 still
|
|
380
|
+
* measures whether that writer agrees with the baked head, a stale entry is
|
|
381
|
+
* itself a violation, and (C-420) any host the declaration names is measured
|
|
382
|
+
* against its live response.
|
|
383
|
+
*/
|
|
384
|
+
exemptions?: Record<string, HeadContractExemptionValue>;
|
|
385
|
+
/**
|
|
386
|
+
* `host -> what that host actually did`, supplied by the caller. An exemption
|
|
387
|
+
* that names a host and finds no observation here FAILS: "not measured" and
|
|
388
|
+
* "measured fine" must never render the same.
|
|
389
|
+
*/
|
|
390
|
+
hostProbes?: Record<string, HeadContractHostProbe>;
|
|
391
|
+
}
|
|
392
|
+
interface HeadContractViolation {
|
|
393
|
+
code: HeadContractViolationCode;
|
|
394
|
+
file: string;
|
|
395
|
+
line: number;
|
|
396
|
+
detail: string;
|
|
397
|
+
}
|
|
398
|
+
interface HeadContractWarning {
|
|
399
|
+
code: HeadContractWarningCode;
|
|
400
|
+
file: string;
|
|
401
|
+
detail: string;
|
|
402
|
+
}
|
|
403
|
+
interface ForeignWriterSite {
|
|
404
|
+
file: string;
|
|
405
|
+
line: number;
|
|
406
|
+
mechanism: 'useHead' | 'useSeoMeta' | 'document.title';
|
|
407
|
+
exempted: boolean;
|
|
408
|
+
reason: string | null;
|
|
409
|
+
}
|
|
410
|
+
interface HeadContractReport {
|
|
411
|
+
/** Execution counts FIRST — a rail that did not run must not read as green. */
|
|
412
|
+
scanned: number;
|
|
413
|
+
applyHeadCalls: number;
|
|
414
|
+
argumentFreeCalls: number;
|
|
415
|
+
/** Slugs written as string literals — checked against `.dcs/seo.yaml`. */
|
|
416
|
+
useSeoSlugs: string[];
|
|
417
|
+
/** Slugs composed at runtime — legal, UNCHECKED here, judged by P1. */
|
|
418
|
+
dynamicSlugs: string[];
|
|
419
|
+
foreignWriters: ForeignWriterSite[];
|
|
420
|
+
/** `useHead({script})` blocks that ADD JSON-LD without restating a managed field. */
|
|
421
|
+
additiveHeadCalls: number;
|
|
422
|
+
/**
|
|
423
|
+
* EVERY declared exemption with its live verdict — dumped whether or not it
|
|
424
|
+
* found anything, so "0 refuted" is a measurement of a listed set and not an
|
|
425
|
+
* empty listing wearing a zero.
|
|
426
|
+
*/
|
|
427
|
+
exemptionAudit: HeadContractExemptionAudit[];
|
|
428
|
+
coverage: {
|
|
429
|
+
/** Files scanned (the caller decides what counts as a routed view). */
|
|
430
|
+
views: number;
|
|
431
|
+
/** Files that re-assert the head in some way. */
|
|
432
|
+
asserting: number;
|
|
433
|
+
/** Files that write nothing — SPA navigation to them is stale. */
|
|
434
|
+
silent: string[];
|
|
435
|
+
};
|
|
436
|
+
violations: HeadContractViolation[];
|
|
437
|
+
warnings: HeadContractWarning[];
|
|
438
|
+
ok: boolean;
|
|
439
|
+
}
|
|
440
|
+
type HeadContractMode = 'error' | 'warn' | 'off';
|
|
441
|
+
declare class HeadContractError extends Error {
|
|
442
|
+
readonly report: HeadContractReport;
|
|
443
|
+
constructor(message: string, report: HeadContractReport);
|
|
444
|
+
}
|
|
445
|
+
/** Minimum characters an exemption reason must carry to count as a reason. */
|
|
446
|
+
declare const MIN_EXEMPTION_REASON_LENGTH = 12;
|
|
447
|
+
/**
|
|
448
|
+
* Blank out COMMENTS ONLY — line, block and HTML — tracking string/template
|
|
449
|
+
* literals just well enough not to mistake a `https://…` inside a string for a
|
|
450
|
+
* line comment.
|
|
451
|
+
*
|
|
452
|
+
* Replacement preserves LENGTH and NEWLINES (every removed character becomes a
|
|
453
|
+
* space, newlines survive), so every offset and line number computed on the
|
|
454
|
+
* result is still valid against the original file.
|
|
455
|
+
*
|
|
456
|
+
* WHY COMMENTS ONLY. Two hazards pull in opposite directions and the asymmetry
|
|
457
|
+
* decides it:
|
|
458
|
+
* - boogie-babies' PrivacyView.vue carries a docblock reading "applyHead takes
|
|
459
|
+
* NO arguments". Scanning raw source reports it. So comments MUST go.
|
|
460
|
+
* - Blanking string BODIES as well would let a regex literal containing a
|
|
461
|
+
* quote (`/['"]/`) swallow the following lines and HIDE a real violation.
|
|
462
|
+
* A gate that silently misses is worse than one that occasionally shouts:
|
|
463
|
+
* a false positive is loud and fixed in a minute; a false negative is
|
|
464
|
+
* exactly the class this whole contract exists to kill.
|
|
465
|
+
* So string bodies are left intact, and an `applyHead(` written inside a string
|
|
466
|
+
* literal is deliberately reported.
|
|
467
|
+
*/
|
|
468
|
+
declare function blankComments(source: string): string;
|
|
469
|
+
/** A bare string exemption is a record with no host claims. */
|
|
470
|
+
declare function normalizeExemption(value: HeadContractExemptionValue): HeadContractExemptionRecord;
|
|
471
|
+
/** Every host the prose names, deduped and normalised. */
|
|
472
|
+
declare function hostsNamedIn(reason: string): string[];
|
|
473
|
+
/**
|
|
474
|
+
* Pull the three head fields an exemption can make a claim about out of a live
|
|
475
|
+
* response body. Shared by the runner and by this module's own tests so the
|
|
476
|
+
* thing that fetches and the thing that judges cannot disagree about what a
|
|
477
|
+
* `robots` meta is.
|
|
478
|
+
*/
|
|
479
|
+
declare function parseHostProbeHtml(html: string): {
|
|
480
|
+
robots: string | null;
|
|
481
|
+
canonical: string | null;
|
|
482
|
+
title: string | null;
|
|
483
|
+
};
|
|
484
|
+
/**
|
|
485
|
+
* Judge every exemption against the live behaviour of the host it names.
|
|
486
|
+
*
|
|
487
|
+
* Four ways to fail and only one way to pass, because the failure this exists
|
|
488
|
+
* to catch is a green:
|
|
489
|
+
* - the prose names a host that carries no machine-checkable claim
|
|
490
|
+
* - a declared host asserts nothing (a claim block with no claims)
|
|
491
|
+
* - a declared host has no observation (nothing was measured)
|
|
492
|
+
* - an observation disagrees with a claim, or the probe itself did not run
|
|
493
|
+
* Pure: it fetches nothing and reads no files.
|
|
494
|
+
*/
|
|
495
|
+
declare function auditExemptionHosts(exemptions: Record<string, HeadContractExemptionValue>, hostProbes: Record<string, HeadContractHostProbe>): {
|
|
496
|
+
audits: HeadContractExemptionAudit[];
|
|
497
|
+
violations: HeadContractViolation[];
|
|
498
|
+
};
|
|
499
|
+
/**
|
|
500
|
+
* Audit a set of source files against the head-authority contract.
|
|
501
|
+
*
|
|
502
|
+
* Site-agnostic by construction: there is no site name, no path convention and
|
|
503
|
+
* no per-shape branch anywhere in this function. The three measured shapes
|
|
504
|
+
* (KEPT / boogie-babies / bryans) go through the identical call.
|
|
505
|
+
*/
|
|
506
|
+
declare function auditHeadContract(input: HeadContractInput): HeadContractReport;
|
|
507
|
+
/**
|
|
508
|
+
* Dump the exemption set with a per-entry live verdict.
|
|
509
|
+
*
|
|
510
|
+
* Printed on EVERY run, including the empty one. "0 refuted" out of an unlisted
|
|
511
|
+
* set is indistinguishable from a rail that inspected nothing, so the set is
|
|
512
|
+
* always enumerated and the empty case says so in words.
|
|
513
|
+
*/
|
|
514
|
+
declare function formatExemptionAudit(report: HeadContractReport): string[];
|
|
515
|
+
/** Human-readable report. Execution counts are printed FIRST, always. */
|
|
516
|
+
declare function formatHeadContractReport(report: HeadContractReport): string;
|
|
517
|
+
/**
|
|
518
|
+
* Assert the contract. `error` throws; `warn` prints; `off` is a no-op — except
|
|
519
|
+
* that `scan-empty` and `no-seo-config` are NEVER downgraded, because "the rail
|
|
520
|
+
* did not run" must not be silenceable by the mode that silences findings.
|
|
521
|
+
*/
|
|
522
|
+
declare function assertHeadContract(input: HeadContractInput, mode?: HeadContractMode, log?: (msg: string) => void): HeadContractReport;
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* P2 — emitted-URL resolution rail.
|
|
526
|
+
*
|
|
527
|
+
* ## The defect this exists for
|
|
528
|
+
*
|
|
529
|
+
* The SEO factory publishes URLs on the site's behalf: the `logo` and `image`
|
|
530
|
+
* IRIs in the `@graph`, `og:image` / `twitter:image`, favicon and touch-icon
|
|
531
|
+
* links, `<loc>`s in `sitemap.xml`, and the page links in `llms.txt`. Nothing
|
|
532
|
+
* has ever asserted that any of them resolve.
|
|
533
|
+
*
|
|
534
|
+
* Measured on live production (2026-07-27):
|
|
535
|
+
*
|
|
536
|
+
* ```
|
|
537
|
+
* GET /logos/iron-oak-mark-light.svg 200 text/html 67,532 bytes
|
|
538
|
+
* GET / 200 text/html 67,532 bytes
|
|
539
|
+
* sha256(both) = e83f660fc39e3ec2… ← byte-identical
|
|
540
|
+
* ```
|
|
541
|
+
*
|
|
542
|
+
* `Organization.logo` and `HomeAndConstructionBusiness.logo` on **every route**
|
|
543
|
+
* of the fleet's flagship point at a file that does not exist. There is no
|
|
544
|
+
* `site/public/logos/` directory in the repo at all. The miss is invisible
|
|
545
|
+
* because Azure Static Web Apps' `navigationFallback` answers any unmatched path
|
|
546
|
+
* with `index.html` and a 200 — so a knowledge-graph entity's logo is a web
|
|
547
|
+
* page, no 404 is ever logged, and no console error is ever raised. `dcs-seo`
|
|
548
|
+
* calls this shape "worse than a 404".
|
|
549
|
+
*
|
|
550
|
+
* ## The two checks, and why they are split
|
|
551
|
+
*
|
|
552
|
+
* **Same-origin assets are checked against `dist/` — no network at all.** If the
|
|
553
|
+
* factory emitted `https://site.com/logos/x.svg` and `dist/logos/x.svg` does not
|
|
554
|
+
* exist, then the host has nothing to serve and its SPA fallback WILL answer
|
|
555
|
+
* HTML. That inference is exact, deterministic, offline, and instant, so it is a
|
|
556
|
+
* hard build failure. This is the check that catches the Iron Oak defect.
|
|
557
|
+
*
|
|
558
|
+
* **Cross-origin assets need the network, so they are designed not to flake.**
|
|
559
|
+
* A CDN URL can only be verified by asking. The policy is deliberately
|
|
560
|
+
* asymmetric:
|
|
561
|
+
* - a *definitive* answer that is wrong (HTML where an image was promised, or
|
|
562
|
+
* a 4xx/5xx) is a HARD FAILURE — that is the defect, and the answer is not
|
|
563
|
+
* in doubt;
|
|
564
|
+
* - a *non-answer* (DNS failure, timeout, connection reset, exhausted budget)
|
|
565
|
+
* is a WARNING and never fails the build.
|
|
566
|
+
*
|
|
567
|
+
* That asymmetry is the whole point. A gate that reds when someone's wifi
|
|
568
|
+
* hiccups gets disabled within a week and then protects nothing — the exact
|
|
569
|
+
* vacuous-green failure mode this campaign keeps finding. Results are cached
|
|
570
|
+
* with a TTL and the whole pass runs under a total time budget, so the marginal
|
|
571
|
+
* cost on a warm CI run is ~zero.
|
|
572
|
+
*/
|
|
573
|
+
/** What kind of resource a URL is expected to return. */
|
|
574
|
+
type UrlExpectation =
|
|
575
|
+
/** Must NOT be HTML — an image, icon, font, or text/xml artifact. */
|
|
576
|
+
'asset'
|
|
577
|
+
/** An HTML page (canonical, sitemap entry, llms.txt page link). */
|
|
578
|
+
| 'page'
|
|
579
|
+
/** An identifier or a third-party profile — never fetched. */
|
|
580
|
+
| 'skip';
|
|
581
|
+
/** One URL the factory emitted, with where it came from. */
|
|
582
|
+
interface EmittedUrl {
|
|
583
|
+
/** The URL exactly as emitted. */
|
|
584
|
+
url: string;
|
|
585
|
+
/** Human-readable provenance, e.g. `JSON-LD Organization.logo`. */
|
|
586
|
+
source: string;
|
|
587
|
+
/** The emitted file this came from (`/services`, `sitemap.xml`, …). */
|
|
588
|
+
from: string;
|
|
589
|
+
expectation: UrlExpectation;
|
|
590
|
+
}
|
|
591
|
+
/** A URL that resolved to the wrong thing. */
|
|
592
|
+
interface EmittedUrlViolation {
|
|
593
|
+
url: string;
|
|
594
|
+
source: string;
|
|
595
|
+
from: string;
|
|
596
|
+
reason: string;
|
|
597
|
+
}
|
|
598
|
+
/** A URL we could not get a definitive answer about. */
|
|
599
|
+
interface EmittedUrlWarning {
|
|
600
|
+
url: string;
|
|
601
|
+
source: string;
|
|
602
|
+
from: string;
|
|
603
|
+
reason: string;
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Collect every URL an emitted HTML document publishes: OG/Twitter images,
|
|
607
|
+
* asset and page `<link>`s, and every asset/page IRI inside its JSON-LD blocks.
|
|
608
|
+
*/
|
|
609
|
+
declare function collectUrlsFromHtml(html: string, from: string): EmittedUrl[];
|
|
610
|
+
/** Collect `<loc>` entries from an emitted sitemap. */
|
|
611
|
+
declare function collectUrlsFromSitemap(xml: string, from?: string): EmittedUrl[];
|
|
612
|
+
/** Collect the markdown link targets from an emitted `llms.txt`. */
|
|
613
|
+
declare function collectUrlsFromLlmsTxt(txt: string, from?: string): EmittedUrl[];
|
|
614
|
+
/** De-duplicate by URL+expectation, keeping the first provenance seen. */
|
|
615
|
+
declare function dedupeUrls(urls: EmittedUrl[]): EmittedUrl[];
|
|
616
|
+
/** How a URL relates to the site being built. */
|
|
617
|
+
type UrlLocality = 'same-origin' | 'remote' | 'unfetchable';
|
|
618
|
+
/** The parsed form of an emitted URL. */
|
|
619
|
+
interface ClassifiedUrl extends EmittedUrl {
|
|
620
|
+
locality: UrlLocality;
|
|
621
|
+
/** Path portion, for same-origin resolution against `dist/`. */
|
|
622
|
+
pathname: string;
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Decide whether a URL points back at the site we just built (so it can be
|
|
626
|
+
* proven against `dist/` offline) or somewhere else (so it needs the network).
|
|
627
|
+
*
|
|
628
|
+
* Protocol-relative and root-relative URLs are treated as same-origin, matching
|
|
629
|
+
* how a browser resolves them from the emitted document.
|
|
630
|
+
*/
|
|
631
|
+
declare function classifyUrl(entry: EmittedUrl, siteUrl: string | undefined): ClassifiedUrl;
|
|
632
|
+
/**
|
|
633
|
+
* Assert that every same-origin ASSET URL the factory emitted has a real file in
|
|
634
|
+
* `dist/`.
|
|
635
|
+
*
|
|
636
|
+
* This is the check that catches the Iron Oak logo IRI, and it does so with no
|
|
637
|
+
* network, no browser, and no ambiguity: an emitted `https://site/logos/x.svg`
|
|
638
|
+
* with no `dist/logos/x.svg` behind it means the host's SPA fallback answers the
|
|
639
|
+
* crawler with `index.html`.
|
|
640
|
+
*/
|
|
641
|
+
declare function checkSameOriginAssets(params: {
|
|
642
|
+
urls: ClassifiedUrl[];
|
|
643
|
+
outDir: string;
|
|
644
|
+
base?: string;
|
|
645
|
+
allow?: string[];
|
|
646
|
+
}): EmittedUrlViolation[];
|
|
647
|
+
/** What the same-origin half actually did — the numbers behind its claim. */
|
|
648
|
+
interface SameOriginAuditResult {
|
|
649
|
+
violations: EmittedUrlViolation[];
|
|
650
|
+
/** Same-origin ASSET URLs actually resolved against `dist/`. */
|
|
651
|
+
checked: number;
|
|
652
|
+
/** Same-origin asset URLs skipped because they are on the allow-list. */
|
|
653
|
+
exempted: number;
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* The counting form of {@link checkSameOriginAssets}.
|
|
657
|
+
*
|
|
658
|
+
* C-338: the caller used to report `classified.length` ("N published URL(s)
|
|
659
|
+
* verified") while this function only ever looks at `expectation === 'asset'`.
|
|
660
|
+
* Page URLs — every sitemap `<loc>`, every canonical, every llms.txt page link —
|
|
661
|
+
* were counted as verified without being touched. The claim must never be
|
|
662
|
+
* broader than the check, so the check now returns its own denominator.
|
|
663
|
+
*/
|
|
664
|
+
declare function auditSameOriginAssets(params: {
|
|
665
|
+
urls: ClassifiedUrl[];
|
|
666
|
+
outDir: string;
|
|
667
|
+
base?: string;
|
|
668
|
+
allow?: string[];
|
|
669
|
+
}): SameOriginAuditResult;
|
|
670
|
+
/** Options for the network half of the rail. */
|
|
671
|
+
interface RemoteCheckOptions {
|
|
672
|
+
/** Per-request timeout. Default 5000ms. */
|
|
673
|
+
timeoutMs?: number;
|
|
674
|
+
/** Total wall-clock budget for the whole pass. Default 20000ms. */
|
|
675
|
+
budgetMs?: number;
|
|
676
|
+
/** Parallel requests. Default 6. */
|
|
677
|
+
concurrency?: number;
|
|
678
|
+
/** Cache file path. Default `<projectRoot>/node_modules/.cache/dcs-seo/url-probes.json`. */
|
|
679
|
+
cacheFile?: string;
|
|
680
|
+
/** Cache TTL. Default 24h. */
|
|
681
|
+
cacheTtlMs?: number;
|
|
682
|
+
/** URLs exempted from the check (still logged). */
|
|
683
|
+
allow?: string[];
|
|
684
|
+
/** Injectable for tests — defaults to global `fetch`. */
|
|
685
|
+
fetchImpl?: typeof fetch;
|
|
686
|
+
debug?: boolean;
|
|
687
|
+
}
|
|
688
|
+
/** Outcome of the remote pass. */
|
|
689
|
+
interface RemoteCheckResult {
|
|
690
|
+
violations: EmittedUrlViolation[];
|
|
691
|
+
warnings: EmittedUrlWarning[];
|
|
692
|
+
/** How many probes were served from cache (0 network cost). */
|
|
693
|
+
cached: number;
|
|
694
|
+
/** How many probes hit the network. */
|
|
695
|
+
probed: number;
|
|
696
|
+
/**
|
|
697
|
+
* Remote ASSET URLs this pass was asked to check (`cached + probed +
|
|
698
|
+
* unanswered`). The honest denominator for the build log — see
|
|
699
|
+
* {@link auditSameOriginAssets} for why the count must come from the checker.
|
|
700
|
+
*/
|
|
701
|
+
checked?: number;
|
|
702
|
+
/** Remote asset URLs skipped because they are on the allow-list. */
|
|
703
|
+
exempted?: number;
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* Probe every REMOTE asset URL and report only what the network told us
|
|
707
|
+
* definitively.
|
|
708
|
+
*
|
|
709
|
+
* The verdict table is the contract:
|
|
710
|
+
* | observation | outcome | why |
|
|
711
|
+
* |--------------------------------------------|----------|-----|
|
|
712
|
+
* | 2xx, non-HTML content-type | pass | the promise held |
|
|
713
|
+
* | 2xx, `text/html` | **FAIL** | the exact silent-degradation shape |
|
|
714
|
+
* | 4xx / 5xx | **FAIL** | a definitive "this does not exist" |
|
|
715
|
+
* | DNS/timeout/reset/abort, budget exhausted | warn | we learned nothing; never red on that |
|
|
716
|
+
*/
|
|
717
|
+
declare function checkRemoteAssets(urls: ClassifiedUrl[], options: RemoteCheckOptions & {
|
|
718
|
+
projectRoot: string;
|
|
719
|
+
}): Promise<RemoteCheckResult>;
|
|
720
|
+
/** Render the failure report — every violation names the URL, where it was
|
|
721
|
+
* emitted, and what actually came back. */
|
|
722
|
+
declare function formatEmittedUrlReport(violations: EmittedUrlViolation[]): string;
|
|
723
|
+
/**
|
|
724
|
+
* Exactly what the P2 pass touched — the numbers the build log is allowed to
|
|
725
|
+
* claim.
|
|
726
|
+
*
|
|
727
|
+
* ## The defect this exists for
|
|
728
|
+
*
|
|
729
|
+
* C-334's success line printed `${classified.length} published URL(s) verified`
|
|
730
|
+
* while BOTH checkers filter `expectation !== 'asset'`. Sitemap `<loc>`s,
|
|
731
|
+
* canonicals and llms.txt page links were counted as verified and never probed —
|
|
732
|
+
* the claim was broader than the check, which is the same vacuous-green shape
|
|
733
|
+
* the rail was built to close. The log now reports these fields verbatim:
|
|
734
|
+
* assets proven / assets probed / pages COLLECTED-BUT-NOT-VERIFIED / unknown.
|
|
735
|
+
*/
|
|
736
|
+
interface EmittedUrlAudit {
|
|
737
|
+
/** Every distinct URL collected from the emitted artifacts. */
|
|
738
|
+
collected: number;
|
|
739
|
+
/** Same-origin asset URLs resolved against `dist/` (offline, exact). */
|
|
740
|
+
sameOriginAssetsChecked: number;
|
|
741
|
+
/** Remote asset URLs that got a definitive answer. */
|
|
742
|
+
remoteAssetsChecked: number;
|
|
743
|
+
/** Remote asset probes that hit the network / came from cache. */
|
|
744
|
+
remoteProbed: number;
|
|
745
|
+
remoteCached: number;
|
|
746
|
+
/**
|
|
747
|
+
* Remote asset URLs the pass could NOT get a definitive answer about (DNS,
|
|
748
|
+
* timeout, budget) plus every remote asset when probing is disabled. Never
|
|
749
|
+
* counted as verified.
|
|
750
|
+
*/
|
|
751
|
+
unknown: number;
|
|
752
|
+
/**
|
|
753
|
+
* Page URLs collected and classified but NOT verified by this rail. This is
|
|
754
|
+
* the honest name for what used to be counted as "verified".
|
|
755
|
+
*/
|
|
756
|
+
pagesUnverified: number;
|
|
757
|
+
/** URLs that are not fetchable by construction (`data:`, `mailto:`, `tel:`). */
|
|
758
|
+
unfetchable: number;
|
|
759
|
+
/** URLs skipped by an explicit allow-list entry (still logged). */
|
|
760
|
+
exempted: number;
|
|
761
|
+
/** Violations found (the failures). */
|
|
762
|
+
failed: number;
|
|
763
|
+
}
|
|
764
|
+
/** Total URLs this pass actually verified. */
|
|
765
|
+
declare function auditCheckedTotal(audit: EmittedUrlAudit): number;
|
|
766
|
+
/**
|
|
767
|
+
* The success/summary line. It states the denominator of every claim and names
|
|
768
|
+
* what was NOT checked, so no reader can infer more coverage than exists.
|
|
769
|
+
*/
|
|
770
|
+
declare function formatEmittedUrlAudit(audit: EmittedUrlAudit, routeCount: number): string;
|
|
771
|
+
/** Thrown in `error` mode when the P2 rail collected nothing to check. */
|
|
772
|
+
declare class EmittedUrlNotRunError extends Error {
|
|
773
|
+
readonly audit: EmittedUrlAudit;
|
|
774
|
+
constructor(audit: EmittedUrlAudit, detail: string);
|
|
775
|
+
}
|
|
776
|
+
/** Thrown in `error` mode so the build goes red. */
|
|
777
|
+
declare class EmittedUrlError extends Error {
|
|
778
|
+
readonly violations: EmittedUrlViolation[];
|
|
779
|
+
constructor(violations: EmittedUrlViolation[]);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* P12 — `<meta charset>` head-budget rail.
|
|
784
|
+
*
|
|
785
|
+
* ## The defect this exists for
|
|
786
|
+
*
|
|
787
|
+
* The HTML spec requires a document's character encoding declaration to appear
|
|
788
|
+
* **within the first 1024 bytes** of the resource; a parser that has not found
|
|
789
|
+
* one by then stops looking and falls back to its locale default. The 2026-07-26
|
|
790
|
+
* Iron Oak review measured the shipped fleet HTML and found:
|
|
791
|
+
*
|
|
792
|
+
* ```
|
|
793
|
+
* <!doctype html>
|
|
794
|
+
* <html lang="en">
|
|
795
|
+
* <head>
|
|
796
|
+
* <style data-dcs-motion-tokens="">:root { … 858 bytes of tokens … }</style>
|
|
797
|
+
* <meta charset="UTF-8" /> ← byte 910 of a 1024-byte window
|
|
798
|
+
* ```
|
|
799
|
+
*
|
|
800
|
+
* The cms motion-token `<style>` was injected `head-prepend`, i.e. ABOVE the
|
|
801
|
+
* site's own `<meta charset>`, leaving 90 bytes of headroom past the end of the
|
|
802
|
+
* declaration. The SWA response also sends `Content-Type: text/html` with **no**
|
|
803
|
+
* `charset` parameter, so there is no header-level backstop. One more head
|
|
804
|
+
* injection — or a handful of new motion tokens — silently mojibakes every cms
|
|
805
|
+
* SPA in the fleet, on every route, with nothing failing.
|
|
806
|
+
*
|
|
807
|
+
* That is the exact "a capability silently undone downstream, with nothing
|
|
808
|
+
* noticing" class this module closes.
|
|
809
|
+
*
|
|
810
|
+
* ## The fix + the rail
|
|
811
|
+
*
|
|
812
|
+
* - **C-414 fixed the source.** `dcsMotionTokensStyleTag()` now injects `head`
|
|
813
|
+
* (append) rather than `head-prepend`, so the site's own declaration stays
|
|
814
|
+
* first in `<head>` and the block still lands above the bundled site CSS.
|
|
815
|
+
* This is the only fix that reaches VitePress sites, which never run the
|
|
816
|
+
* `emitStaticHtml` path below.
|
|
817
|
+
* - {@link hoistCharsetMeta} moves the encoding declaration to the very top of
|
|
818
|
+
* `<head>` (inserting `<meta charset="utf-8" />` when the document has none).
|
|
819
|
+
* Pure, idempotent, and cascade-safe: only the charset meta moves, so the
|
|
820
|
+
* motion-token `<style>` keeps its position relative to the site's own CSS.
|
|
821
|
+
* Since C-414 this is a no-op on a stock cms shell — it stays as the backstop
|
|
822
|
+
* for hand-rolled shells and any future head injector.
|
|
823
|
+
* - {@link checkCharsetBudget} measures the real BYTE offset of the declaration
|
|
824
|
+
* in the emitted document and reports headroom, so a regression is caught even
|
|
825
|
+
* if the hoist is ever bypassed.
|
|
826
|
+
*
|
|
827
|
+
* Everything here is framework-free and `fs`-free so both the Vite plugin path
|
|
828
|
+
* and the unit tests share one implementation.
|
|
829
|
+
*/
|
|
830
|
+
/**
|
|
831
|
+
* The spec's encoding-sniffing window. Per HTML Standard §13.2.3.2 ("determining
|
|
832
|
+
* the character encoding") a prescan examines at most the first 1024 bytes.
|
|
833
|
+
*/
|
|
834
|
+
declare const CHARSET_BUDGET_BYTES = 1024;
|
|
835
|
+
/**
|
|
836
|
+
* Recommended minimum slack between the declaration and the end of the window.
|
|
837
|
+
* Below this the document is *technically* conformant but one head injection
|
|
838
|
+
* away from breaking — the Iron Oak measurement (114 bytes) is precisely this
|
|
839
|
+
* shape, which is why the rail reports headroom and not just pass/fail.
|
|
840
|
+
*/
|
|
841
|
+
declare const CHARSET_HEADROOM_WARN_BYTES = 256;
|
|
842
|
+
/** The canonical declaration this module inserts when a document has none. */
|
|
843
|
+
declare const CANONICAL_CHARSET_META = "<meta charset=\"utf-8\" />";
|
|
844
|
+
/** The result of measuring a document against the encoding-sniffing window. */
|
|
845
|
+
interface CharsetBudgetResult {
|
|
846
|
+
/** `true` when a declaration exists AND its last byte is inside the window. */
|
|
847
|
+
ok: boolean;
|
|
848
|
+
/** Byte offset of the START of the declaration, or `-1` when absent. */
|
|
849
|
+
offset: number;
|
|
850
|
+
/** Byte offset of the END of the declaration, or `-1` when absent. */
|
|
851
|
+
endOffset: number;
|
|
852
|
+
/** `limitBytes - endOffset`; negative when the declaration is out of budget. */
|
|
853
|
+
headroom: number;
|
|
854
|
+
/** The window used for the measurement (default {@link CHARSET_BUDGET_BYTES}). */
|
|
855
|
+
limitBytes: number;
|
|
856
|
+
/** The matched declaration text, for reporting. */
|
|
857
|
+
declaration: string | null;
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* Byte offset (UTF-8) of the character encoding declaration, or `-1`.
|
|
861
|
+
*
|
|
862
|
+
* Deliberately measured in BYTES, not characters: the spec's window is a byte
|
|
863
|
+
* window, and a head containing non-ASCII (a `©` in a `<title>`, an em dash in a
|
|
864
|
+
* meta description) makes the character index an under-estimate — the exact way
|
|
865
|
+
* a naive check would report "fine" on a document that is not.
|
|
866
|
+
*/
|
|
867
|
+
declare function findCharsetByteOffset(html: string): number;
|
|
868
|
+
/**
|
|
869
|
+
* Measure a document against the encoding-sniffing window.
|
|
870
|
+
*
|
|
871
|
+
* The declaration must END inside the window to count — a `<meta charset>` that
|
|
872
|
+
* straddles byte 1024 is not seen by the prescan.
|
|
873
|
+
*/
|
|
874
|
+
declare function checkCharsetBudget(html: string, options?: {
|
|
875
|
+
limitBytes?: number;
|
|
876
|
+
}): CharsetBudgetResult;
|
|
877
|
+
/**
|
|
878
|
+
* Move the character encoding declaration to the first position inside `<head>`
|
|
879
|
+
* — or insert the canonical one when the document has none.
|
|
880
|
+
*
|
|
881
|
+
* Guarantees, in order of importance:
|
|
882
|
+
* 1. **Idempotent.** Re-running on already-hoisted HTML is a no-op (the
|
|
883
|
+
* declaration is already the first child of `<head>`), so it is safe to
|
|
884
|
+
* apply in `transformIndexHtml` AND again over the emitted per-route files.
|
|
885
|
+
* 2. **Minimal.** Only the charset meta moves. The motion-token `<style>`, the
|
|
886
|
+
* site's CSS links, and the app script keep their relative order, so no
|
|
887
|
+
* cascade or execution-order behaviour changes.
|
|
888
|
+
* 3. **Total.** A document with no `<head>` is returned unchanged rather than
|
|
889
|
+
* rewritten — the caller logs and no-ops (never corrupt a non-SPA shell).
|
|
890
|
+
*
|
|
891
|
+
* @param html - a full HTML document
|
|
892
|
+
* @param options.insertIfMissing - insert {@link CANONICAL_CHARSET_META} when
|
|
893
|
+
* the document declares no encoding at all (default `true`). A cms SPA shell
|
|
894
|
+
* always has one; a hand-rolled template might not, and shipping *no*
|
|
895
|
+
* declaration is strictly worse than shipping a late one.
|
|
896
|
+
*/
|
|
897
|
+
declare function hoistCharsetMeta(html: string, options?: {
|
|
898
|
+
insertIfMissing?: boolean;
|
|
899
|
+
}): string;
|
|
900
|
+
/** Thrown when an emitted document's encoding declaration is out of budget. */
|
|
901
|
+
declare class CharsetBudgetError extends Error {
|
|
902
|
+
readonly file: string;
|
|
903
|
+
readonly result: CharsetBudgetResult;
|
|
904
|
+
constructor(file: string, result: CharsetBudgetResult);
|
|
905
|
+
}
|
|
906
|
+
/**
|
|
907
|
+
* Human-readable one-liner for the low-headroom (still-passing) case, so a
|
|
908
|
+
* document creeping toward the cliff is visible in the build log before it falls
|
|
909
|
+
* off it. Returns `null` when there is nothing worth saying.
|
|
910
|
+
*/
|
|
911
|
+
declare function formatCharsetHeadroomWarning(file: string, result: CharsetBudgetResult, warnBelow?: number): string | null;
|
|
912
|
+
|
|
913
|
+
export { type BodyPrerenderEnableInput, BodyPrerenderError, CANONICAL_CHARSET_META, CHARSET_BUDGET_BYTES, CHARSET_HEADROOM_WARN_BYTES, CharsetBudgetError, type CharsetBudgetResult, type ClassifiedUrl, type EmittedUrl, type EmittedUrlAudit, EmittedUrlError, EmittedUrlNotRunError, type EmittedUrlViolation, type EmittedUrlWarning, type FilteredRoute, type ForeignWriterSite, HeadContractError, type HeadContractExemptionAudit, type HeadContractExemptionCheck, type HeadContractExemptionExpectation, type HeadContractExemptionRecord, type HeadContractExemptionValue, type HeadContractExemptionVerdict, type HeadContractHostProbe, type HeadContractInput, type HeadContractMode, type HeadContractReport, type HeadContractSeoConfig, type HeadContractSourceFile, type HeadContractViolation, type HeadContractViolationCode, type HeadContractWarning, type HeadContractWarningCode, HeadObservation, MIN_EXEMPTION_REASON_LENGTH, PageRouteEntry, type PrerenderBodiesParams, type PrerenderBodiesResult, type PrerenderFilterReason, type RemoteCheckOptions, type RemoteCheckResult, type RenderedHead, type RenderedRoute, type RouteRenderer, type SameOriginAuditResult, type UrlExpectation, type UrlLocality, assertHeadContract, auditCheckedTotal, auditExemptionHosts, auditHeadContract, auditSameOriginAssets, blankComments, checkCharsetBudget, checkRemoteAssets, checkSameOriginAssets, classifyUrl, collectUrlsFromHtml, collectUrlsFromLlmsTxt, collectUrlsFromSitemap, dedupeUrls, findCharsetByteOffset, formatCharsetHeadroomWarning, formatEmittedUrlAudit, formatEmittedUrlReport, formatExemptionAudit, formatHeadContractReport, hoistCharsetMeta, hostsNamedIn, isBodyPrerenderEnabled, normalizeExemption, parseHostProbeHtml, prerenderBodies, routeToOutputFile, spliceBodyHtml };
|