jamdesk 1.1.147 → 1.1.149

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=seo-ai-schema.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"seo-ai-schema.test.d.ts","sourceRoot":"","sources":["../../../src/__tests__/unit/seo-ai-schema.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,40 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { createRequire } from 'module';
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import { fileURLToPath } from 'url';
6
+ const require = createRequire(import.meta.url);
7
+ const Ajv = require('ajv').default || require('ajv');
8
+ // Load the schema via fs (not `import ... from '.json'`): the CLI is module:NodeNext,
9
+ // where a JSON import needs an import attribute. fs.readFileSync mirrors how the real
10
+ // validate command (src/lib/docs-config.ts) and other CLI tests load bundled JSON.
11
+ const schemaDir = path.dirname(fileURLToPath(import.meta.url));
12
+ const schema = JSON.parse(fs.readFileSync(path.join(schemaDir, '../../../vendored/schema/docs-schema.json'), 'utf-8'));
13
+ // Minimal config that satisfies anyOf[0] (jam theme) with no extra properties.
14
+ // Required: theme (const "jam"), name, navigation (must match one of the
15
+ // navigationSchema anyOf variants; anyOf[6] = { pages: [] } is the simplest).
16
+ const BASE = {
17
+ theme: 'jam',
18
+ name: 'Test',
19
+ navigation: { pages: [] },
20
+ };
21
+ describe('docs.json schema: seo.ai.llmsTxt', () => {
22
+ const compile = () => new Ajv({ allErrors: true, strict: false, logger: false }).compile(schema);
23
+ it('baseline config validates (control)', () => {
24
+ const validate = compile();
25
+ validate(BASE);
26
+ expect(validate.errors ?? []).toEqual([]);
27
+ });
28
+ it('accepts seo.ai.llmsTxt: false', () => {
29
+ const validate = compile();
30
+ const ok = validate({ ...BASE, seo: { ...BASE.seo, ai: { llmsTxt: false } } });
31
+ expect(validate.errors ?? []).toEqual([]);
32
+ expect(ok).toBe(true);
33
+ });
34
+ it('rejects an unknown key under seo.ai (additionalProperties:false)', () => {
35
+ const validate = compile();
36
+ validate({ ...BASE, seo: { ...BASE.seo, ai: { bogus: true } } });
37
+ expect((validate.errors ?? []).length).toBeGreaterThan(0);
38
+ });
39
+ });
40
+ //# sourceMappingURL=seo-ai-schema.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"seo-ai-schema.test.js","sourceRoot":"","sources":["../../../src/__tests__/unit/seo-ai-schema.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAC9C,OAAO,EAAE,aAAa,EAAE,MAAM,QAAQ,CAAC;AACvC,OAAO,EAAE,MAAM,IAAI,CAAC;AACpB,OAAO,IAAI,MAAM,MAAM,CAAC;AACxB,OAAO,EAAE,aAAa,EAAE,MAAM,KAAK,CAAC;AAEpC,MAAM,OAAO,GAAG,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AAC/C,MAAM,GAAG,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC,OAAO,IAAI,OAAO,CAAC,KAAK,CAAC,CAAC;AACrD,sFAAsF;AACtF,sFAAsF;AACtF,mFAAmF;AACnF,MAAM,SAAS,GAAG,IAAI,CAAC,OAAO,CAAC,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;AAC/D,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CACvB,EAAE,CAAC,YAAY,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,2CAA2C,CAAC,EAAE,OAAO,CAAC,CAC5F,CAAC;AAEF,+EAA+E;AAC/E,yEAAyE;AACzE,8EAA8E;AAC9E,MAAM,IAAI,GAAG;IACT,KAAK,EAAE,KAAK;IACZ,IAAI,EAAE,MAAM;IACZ,UAAU,EAAE,EAAE,KAAK,EAAE,EAAE,EAAE;CAC5B,CAAC;AAEF,QAAQ,CAAC,kCAAkC,EAAE,GAAG,EAAE;IAC9C,MAAM,OAAO,GAAG,GAAG,EAAE,CAAC,IAAI,GAAG,CAAC,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,CAAC,OAAO,CAAC,MAAgB,CAAC,CAAC;IAE3G,EAAE,CAAC,qCAAqC,EAAE,GAAG,EAAE;QAC3C,MAAM,QAAQ,GAAG,OAAO,EAAE,CAAC;QAC3B,QAAQ,CAAC,IAAI,CAAC,CAAC;QACf,MAAM,CAAC,QAAQ,CAAC,MAAM,IAAI,EAAE,CAAC,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC;IAC9C,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,+BAA+B,EAAE,GAAG,EAAE;QACrC,MAAM,QAAQ,GAAG,OAAO,EAAE,CAAC;QAC3B,MAAM,EAAE,GAAG,QAAQ,CAAC,EAAE,GAAG,IAAI,EAAE,GAAG,EAAE,EAAE,GAAI,IAAY,CAAC,GAAG,EAAE,EAAE,EAAE,EAAE,OAAO,EAAE,KAAK,EAAE,EAAE,EAAE,CAAC,CAAC;QACxF,MAAM,CAAC,QAAQ,CAAC,MAAM,IAAI,EAAE,CAAC,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC;QAC1C,MAAM,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAC1B,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,kEAAkE,EAAE,GAAG,EAAE;QACxE,MAAM,QAAQ,GAAG,OAAO,EAAE,CAAC;QAC3B,QAAQ,CAAC,EAAE,GAAG,IAAI,EAAE,GAAG,EAAE,EAAE,GAAI,IAAY,CAAC,GAAG,EAAE,EAAE,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC,CAAC;QAC1E,MAAM,CAAC,CAAC,QAAQ,CAAC,MAAM,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,eAAe,CAAC,CAAC,CAAC,CAAC;IAC9D,CAAC,CAAC,CAAC;AACP,CAAC,CAAC,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jamdesk",
3
- "version": "1.1.147",
3
+ "version": "1.1.149",
4
4
  "description": "CLI for Jamdesk — build, preview, and deploy documentation sites from MDX. Dev server with hot reload, 50+ components, OpenAPI support, AI search, and Mintlify migration",
5
5
  "keywords": [
6
6
  "jamdesk",
@@ -0,0 +1,87 @@
1
+ // builder/build-service/components/ConsentGate.tsx
2
+ 'use client';
3
+
4
+ import { useEffect, useRef } from 'react';
5
+ import type { GatedScript } from '../lib/consent-gating';
6
+
7
+ const POLL_MS = 1000;
8
+
9
+ function hasConsent(key: string, value: string): boolean {
10
+ try {
11
+ return window.localStorage.getItem(key) === value;
12
+ } catch {
13
+ // Storage unavailable (private mode / blocked iframe) → treat as no consent
14
+ return false;
15
+ }
16
+ }
17
+
18
+ function injectScripts(scripts: GatedScript[]): void {
19
+ for (const s of scripts) {
20
+ const el = document.createElement('script');
21
+ el.setAttribute('data-jd-consent-script', s.id);
22
+ if (s.kind === 'src' && s.src) {
23
+ el.src = s.src;
24
+ for (const [name, val] of Object.entries(s.attrs ?? {})) el.setAttribute(name, val);
25
+ } else if (s.kind === 'inline' && s.code) {
26
+ el.textContent = s.code;
27
+ } else {
28
+ continue;
29
+ }
30
+ (s.appendTo === 'head' ? document.head : document.body).appendChild(el);
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Consent gate for analytics/tracking scripts (integrations.cookies).
36
+ * Renders nothing. Injects the given scripts once localStorage[consentKey]
37
+ * equals consentValue — immediately on mount if consent pre-exists, else via
38
+ * a storage listener (cross-tab) + poll (same-tab CMP writes don't fire
39
+ * storage events). Scripts are injected at most once per page load; consent
40
+ * revocation takes effect on the next page load.
41
+ */
42
+ export function ConsentGate({
43
+ consentKey,
44
+ consentValue,
45
+ scripts,
46
+ }: {
47
+ consentKey: string;
48
+ consentValue: string;
49
+ scripts: GatedScript[];
50
+ }) {
51
+ // scripts is a fresh array identity each render (built server-side, serialized
52
+ // across the RSC boundary). Hold it in a ref so the effect doesn't list it as
53
+ // a dependency — otherwise a parent re-render before consent arrives would
54
+ // tear down and restart the polling window. Content is stable per page.
55
+ const scriptsRef = useRef(scripts);
56
+ scriptsRef.current = scripts;
57
+
58
+ useEffect(() => {
59
+ if ((window as any).__jdConsentInjected) return;
60
+ let intervalId: ReturnType<typeof setInterval> | undefined;
61
+
62
+ const stop = () => {
63
+ if (intervalId !== undefined) clearInterval(intervalId);
64
+ window.removeEventListener('storage', check);
65
+ };
66
+ const check = () => {
67
+ if ((window as any).__jdConsentInjected) { stop(); return; }
68
+ if (!hasConsent(consentKey, consentValue)) return;
69
+ (window as any).__jdConsentInjected = true;
70
+ stop();
71
+ injectScripts(scriptsRef.current);
72
+ };
73
+
74
+ check();
75
+ if (!(window as any).__jdConsentInjected) {
76
+ // Diagnostics: wrong key/value otherwise fails silently forever.
77
+ console.debug(
78
+ `[Jamdesk] Consent gating active — analytics withheld until localStorage["${consentKey}"] equals the configured value.`,
79
+ );
80
+ window.addEventListener('storage', check);
81
+ intervalId = setInterval(check, POLL_MS);
82
+ }
83
+ return stop;
84
+ }, [consentKey, consentValue]);
85
+
86
+ return null;
87
+ }
@@ -35,6 +35,10 @@ export const Accordion = memo(function Accordion({
35
35
 
36
36
  return (
37
37
  <div className="border border-[var(--color-border)] rounded-lg mb-2 last:mb-0 not-prose overflow-hidden">
38
+ {/* WAI-ARIA accordion pattern: the heading WRAPS the toggle button. A heading
39
+ nested inside the button would be pruned from the a11y tree (button name is
40
+ computed from contents) and flagged by axe — so it goes on the outside. */}
41
+ <div role="heading" aria-level={3}>
38
42
  <button
39
43
  type="button"
40
44
  onClick={() => setIsOpen(!isOpen)}
@@ -66,7 +70,8 @@ export const Accordion = memo(function Accordion({
66
70
  aria-hidden="true"
67
71
  />
68
72
  </button>
69
-
73
+ </div>
74
+
70
75
  {/* Content */}
71
76
  <div
72
77
  className={`overflow-hidden transition-all duration-200 ${
@@ -414,7 +414,20 @@ export const MDXComponents = {
414
414
  // Check for mermaid diagrams - render with Mermaid component
415
415
  if (language === 'mermaid') {
416
416
  const diagramCode = extractTextFromChildren(children);
417
- return <Mermaid>{diagramCode}</Mermaid>;
417
+ return (
418
+ <>
419
+ <Mermaid>{diagramCode}</Mermaid>
420
+ {/* Crawler / AI fallback: the client-only SVG leaves the server HTML empty.
421
+ Use <noscript>, NOT sr-only: a JS-disabled crawler reads it, while users
422
+ with JS (and screen readers) get the real rendered diagram and never see
423
+ duplicate text. */}
424
+ {diagramCode.trim() && (
425
+ <noscript>
426
+ <pre>{diagramCode}</pre>
427
+ </noscript>
428
+ )}
429
+ </>
430
+ );
418
431
  }
419
432
 
420
433
  // Check for HTTP code blocks - render with special styling
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
3
+ import { useEffect, useLayoutEffect, useRef, useState } from 'react';
4
4
  import mermaid from 'mermaid';
5
5
  import {
6
6
  CACHE_KEY_PREFIX,
@@ -355,11 +355,6 @@ export function MermaidInner({ children, className, minWidth }: MermaidInnerProp
355
355
  });
356
356
  const [error, setError] = useState<string | null>(null);
357
357
 
358
- // Reserves space so the skeleton → SVG swap doesn't shift layout below it.
359
- // Derived from svg (not separate state) so it can't desync. The regex is
360
- // cheap (small string, no backtracking).
361
- const minHeightPx = useMemo(() => (svg ? readSvgHeight(svg) : 0), [svg]);
362
-
363
358
  useEffect(() => {
364
359
  let cancelled = false;
365
360
 
@@ -482,8 +477,7 @@ export function MermaidInner({ children, className, minWidth }: MermaidInnerProp
482
477
  return (
483
478
  <div
484
479
  ref={containerRef}
485
- className={`mermaid-container my-6 flex justify-center overflow-x-auto ${className || ''}`}
486
- style={minHeightPx ? { minHeight: `${minHeightPx}px` } : undefined}
480
+ className={`mermaid-container my-6 flex justify-center items-start overflow-x-auto ${className || ''}`}
487
481
  dangerouslySetInnerHTML={{ __html: svg }}
488
482
  />
489
483
  );
@@ -0,0 +1,119 @@
1
+ // builder/build-service/lib/consent-gating.ts
2
+ // Consent gating for analytics/tracking scripts (integrations.cookies) and
3
+ // CMP loader validation (integrations.osano / integrations.termly).
4
+ // Pure module — no DOM, no React — so the server layout and tests share it.
5
+ import type { IntegrationsConfig } from './docs-types';
6
+
7
+ export interface GatedScript {
8
+ /** Stable id — used as DOM marker (data-jd-consent-script) and React key */
9
+ id: string;
10
+ kind: 'inline' | 'src';
11
+ /** Inline JS source (kind: 'inline') */
12
+ code?: string;
13
+ /** External src (kind: 'src') */
14
+ src?: string;
15
+ /** Extra attributes set via setAttribute (kind: 'src'); '' = boolean attr */
16
+ attrs?: Record<string, string>;
17
+ appendTo: 'head' | 'body';
18
+ }
19
+
20
+ // Tracking-ID guards: these values get interpolated into inline JS / URLs, so
21
+ // they must be strictly validated (same posture as the existing Crisp guard).
22
+ // /i is intentional: tolerate lowercase-typed IDs (alphanumerics + hyphen
23
+ // can't break out of the inline JS string regardless of case).
24
+ const GA4_ID_RE = /^G-[A-Z0-9]+$/i;
25
+ const GTM_ID_RE = /^GTM-[A-Z0-9]+$/i;
26
+ const CRISP_ID_RE = /^[a-f0-9-]{36}$/; // mirrors layout-helpers.tsx guard
27
+
28
+ // CMP loader URL pins. Defense-in-depth: schema validates these too, but the
29
+ // renderer must not trust config blobs from R2. Path charset includes '.' so
30
+ // every schema-valid Osano URL also passes here (schema only pins prefix/suffix).
31
+ const OSANO_SRC_RE = /^https:\/\/cmp\.osano\.com\/[A-Za-z0-9._/-]+\/osano\.js$/;
32
+ const TERMLY_SRC_RE =
33
+ /^https:\/\/app\.termly\.io\/resource-blocker\/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}(\?autoBlock=(on|off))?$/;
34
+
35
+ export function isConsentGatingEnabled(integrations: IntegrationsConfig | undefined): boolean {
36
+ const c = integrations?.cookies;
37
+ return typeof c?.key === 'string' && c.key.length > 0 &&
38
+ typeof c?.value === 'string' && c.value.length > 0;
39
+ }
40
+
41
+ /** Validated CMP script srcs to render ungated in <head> (Osano first). */
42
+ export function getCmpScriptSrcs(integrations: IntegrationsConfig | undefined): string[] {
43
+ const srcs: string[] = [];
44
+ const osano = integrations?.osano?.scriptSource;
45
+ if (osano && OSANO_SRC_RE.test(osano)) srcs.push(osano);
46
+ const termly = integrations?.termly?.scriptSource;
47
+ if (termly && TERMLY_SRC_RE.test(termly)) srcs.push(termly);
48
+ return srcs;
49
+ }
50
+
51
+ /**
52
+ * Build serializable descriptors for every analytics/tracking script.
53
+ * Emitted unconditionally — the caller is responsible for the prod gate:
54
+ * layout-helpers renders <ConsentGate> (and thus calls this) only when
55
+ * NODE_ENV === 'production', matching the dev-suppression the direct render
56
+ * paths already apply to Plausible/Crisp/customJs/first-party analytics.
57
+ */
58
+ export function buildGatedScripts({
59
+ integrations,
60
+ customJs,
61
+ analyticsScript,
62
+ }: {
63
+ integrations: IntegrationsConfig | undefined;
64
+ customJs: string | null;
65
+ analyticsScript: string | null;
66
+ }): GatedScript[] {
67
+ const scripts: GatedScript[] = [];
68
+ const i = integrations ?? {};
69
+
70
+ if (i.plausible?.scriptUrl) {
71
+ // Proxy mode — mirrors PlausibleScript's scriptUrl branch in layout-helpers
72
+ scripts.push({ id: 'plausible-proxy', kind: 'src', src: i.plausible.scriptUrl, attrs: { async: '' }, appendTo: 'head' });
73
+ scripts.push({
74
+ id: 'plausible-proxy-init', kind: 'inline', appendTo: 'head',
75
+ code: 'window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};plausible.init()',
76
+ });
77
+ } else if (i.plausible?.domain) {
78
+ const baseServer = (i.plausible.server || 'https://plausible.io').replace(/\/+$/, '');
79
+ const attrs: Record<string, string> = { defer: '', 'data-domain': i.plausible.domain };
80
+ if (i.plausible.server) attrs['data-api'] = `${baseServer}/api/event`;
81
+ scripts.push({ id: 'plausible', kind: 'src', src: `${baseServer}/js/script.js`, attrs, appendTo: 'head' });
82
+ }
83
+
84
+ // GTM/GA4 here are raw inline equivalents of the standard snippets. The
85
+ // ungated direct renders use @next/third-parties (ConditionalGTM/ConditionalGA)
86
+ // instead; these descriptors deliberately bypass it because they're injected
87
+ // client-side at consent time, not at SSR. Functionally equivalent for tracking,
88
+ // but they carry NO Next.js CSP nonce and GTM omits the <noscript> iframe
89
+ // fallback (irrelevant — no-JS clients can't trigger the consent gate anyway).
90
+ if (i.gtm?.tagId && GTM_ID_RE.test(i.gtm.tagId)) {
91
+ scripts.push({
92
+ id: 'gtm', kind: 'inline', appendTo: 'head',
93
+ code: `(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);})(window,document,'script','dataLayer','${i.gtm.tagId}');`,
94
+ });
95
+ }
96
+
97
+ if (i.ga4?.measurementId && GA4_ID_RE.test(i.ga4.measurementId)) {
98
+ scripts.push({
99
+ id: 'ga4-loader', kind: 'src', appendTo: 'head', attrs: { async: '' },
100
+ src: `https://www.googletagmanager.com/gtag/js?id=${i.ga4.measurementId}`,
101
+ });
102
+ scripts.push({
103
+ id: 'ga4-init', kind: 'inline', appendTo: 'head',
104
+ code: `window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config','${i.ga4.measurementId}');`,
105
+ });
106
+ }
107
+
108
+ if (i.crisp?.websiteId && CRISP_ID_RE.test(i.crisp.websiteId)) {
109
+ scripts.push({
110
+ id: 'crisp', kind: 'inline', appendTo: 'body',
111
+ code: `window.$crisp=[];window.CRISP_WEBSITE_ID="${i.crisp.websiteId}";(function(){var d=document;var s=d.createElement("script");s.src="https://client.crisp.chat/l.js";s.async=1;d.getElementsByTagName("head")[0].appendChild(s)})();`,
112
+ });
113
+ }
114
+
115
+ if (customJs) scripts.push({ id: 'custom-js', kind: 'inline', code: customJs, appendTo: 'body' });
116
+ if (analyticsScript) scripts.push({ id: 'jd-analytics', kind: 'inline', code: analyticsScript, appendTo: 'body' });
117
+
118
+ return scripts;
119
+ }
@@ -644,7 +644,10 @@ export interface NewsletterConfig {
644
644
  }
645
645
 
646
646
  /**
647
- * Analytics and integration configurations (stubs - TODO: implement)
647
+ * Analytics and integration configurations.
648
+ * Implemented: ga4, gtm, plausible, crisp, osano, termly, cookies (consent
649
+ * gating), analytics opt-out via AnalyticsConfig. Others remain config-only
650
+ * stubs accepted for Mintlify compatibility.
648
651
  */
649
652
  export interface IntegrationsConfig {
650
653
  ga4?: { measurementId: string };
@@ -664,6 +667,7 @@ export interface IntegrationsConfig {
664
667
  logrocket?: { appId: string };
665
668
  mixpanel?: { projectToken: string };
666
669
  osano?: { scriptSource: string };
670
+ termly?: { scriptSource: string };
667
671
  pirsch?: { id: string };
668
672
  posthog?: { apiKey: string; apiHost?: string };
669
673
  plausible?: { domain?: string; server?: string; scriptUrl?: string };
@@ -707,6 +711,7 @@ export interface SeoConfig {
707
711
  indexing?: 'navigable' | 'all';
708
712
  /** Include hidden pages in sitemap (default: false) */
709
713
  indexHiddenPages?: boolean;
714
+ ai?: { llmsTxt?: boolean }; // default true; false suppresses llms.txt + llms-full.txt
710
715
  }
711
716
 
712
717
  /**
@@ -0,0 +1,54 @@
1
+ export interface FaqPair { question: string; answer: string; }
2
+
3
+ // Nested component subtrees that are NOT part of the FAQ answer prose (CTAs,
4
+ // cards, media, callouts). Drop them WITH their inner text before flattening.
5
+ const NESTED_BLOCK_TAGS = [
6
+ 'Card', 'CardGroup', 'Columns', 'Frame', 'Note', 'Warning', 'Tip', 'Info',
7
+ 'Steps', 'Step', 'Tabs', 'Tab', 'CodeGroup',
8
+ ];
9
+
10
+ function stripMarkdown(s: string): string {
11
+ return s
12
+ .replace(/```[\s\S]*?```/g, ' ') // fenced code
13
+ .replace(/`([^`]+)`/g, '$1') // inline code
14
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ') // images
15
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') // links → text
16
+ .replace(/(\*\*|__)(.*?)\1/g, '$2') // bold
17
+ .replace(/(\*|_)(.*?)\1/g, '$2') // italic
18
+ .replace(/^#{1,6}\s+/gm, '') // ATX headings
19
+ .replace(/^\s*[-*+]\s+/gm, '') // bullet markers
20
+ .replace(/^\s*\d+\.\s+/gm, ''); // ordered markers
21
+ }
22
+
23
+ // Lightweight extraction of <Accordion title="...">body</Accordion>. Only the
24
+ // QUOTED-title form is matched (the title={expr} form is intentionally skipped —
25
+ // an expression can't be resolved to display text). Answers are reduced to plain
26
+ // text for schema.org: nested component subtrees dropped, markdown stripped.
27
+ //
28
+ // KNOWN LIMITATION (regex, not AST): an <Accordion> nested INSIDE another
29
+ // <Accordion> body truncates the outer answer at the first </Accordion> and
30
+ // merges the inner answer in (the inner question is lost). Harmless for the
31
+ // FAQPage JSON-LD — output stays valid, just an imperfect answer string on the
32
+ // rare nested page. Sibling accordions in an <AccordionGroup> are unaffected.
33
+ // The fix for true nesting is the compiled-MDX AST walk (mdxJsxFlowElement),
34
+ // the documented upgrade path; kept regex-first per the plan's decision.
35
+ export function extractFaqPairs(rawMdx: string): FaqPair[] {
36
+ const pairs: FaqPair[] = [];
37
+ const re = /<Accordion\b[^>]*?\btitle\s*=\s*("|')(.*?)\1[^>]*>([\s\S]*?)<\/Accordion>/g;
38
+ let m: RegExpExecArray | null;
39
+ while ((m = re.exec(rawMdx))) {
40
+ const question = m[2].trim();
41
+ let body = m[3];
42
+ for (const tag of NESTED_BLOCK_TAGS) {
43
+ body = body
44
+ .replace(new RegExp(`<${tag}\\b[^>]*>[\\s\\S]*?<\\/${tag}>`, 'g'), ' ') // paired
45
+ .replace(new RegExp(`<${tag}\\b[^>]*/>`, 'g'), ' '); // self-closing
46
+ }
47
+ const answer = stripMarkdown(body)
48
+ .replace(/<[^>]+>/g, ' ') // any remaining tags
49
+ .replace(/\s+/g, ' ')
50
+ .trim();
51
+ if (question && answer) pairs.push({ question, answer });
52
+ }
53
+ return pairs;
54
+ }
@@ -10,6 +10,7 @@
10
10
 
11
11
  import type { DocsConfig, NavigationPage, GroupConfig } from './docs-types';
12
12
  import { normalizeNavPage } from './docs-types';
13
+ import type { FaqPair } from './extract-faq';
13
14
 
14
15
  interface BreadcrumbEntry {
15
16
  name: string;
@@ -21,6 +22,7 @@ export interface JsonLdOptions {
21
22
  pagePath: string;
22
23
  pageTitle: string;
23
24
  baseUrl: string;
25
+ faqPairs?: FaqPair[];
24
26
  }
25
27
 
26
28
  export interface JsonLdGraph {
@@ -106,7 +108,7 @@ export function findBreadcrumbPath(
106
108
  // Search all navigation structures (mirrors Breadcrumb.tsx order)
107
109
  let trail: BreadcrumbEntry[] | null = null;
108
110
 
109
- if (!trail && nav.languages) {
111
+ if (nav && !trail && nav.languages) {
110
112
  for (const lang of nav.languages) {
111
113
  if (!lang.tabs) continue;
112
114
  for (const tab of lang.tabs) {
@@ -116,19 +118,19 @@ export function findBreadcrumbPath(
116
118
  if (trail) break;
117
119
  }
118
120
  }
119
- if (!trail && nav.anchors) {
121
+ if (nav && !trail && nav.anchors) {
120
122
  for (const anchor of nav.anchors) {
121
123
  if (anchor.groups) trail = searchGroups(anchor.groups);
122
124
  if (trail) break;
123
125
  }
124
126
  }
125
- if (!trail && nav.tabs) {
127
+ if (nav && !trail && nav.tabs) {
126
128
  for (const tab of nav.tabs) {
127
129
  if (tab.groups) trail = searchGroups(tab.groups);
128
130
  if (trail) break;
129
131
  }
130
132
  }
131
- if (!trail && nav.groups) {
133
+ if (nav && !trail && nav.groups) {
132
134
  trail = searchGroups(nav.groups);
133
135
  }
134
136
 
@@ -145,7 +147,7 @@ export function findBreadcrumbPath(
145
147
  * Returns a schema.org @graph with WebSite + BreadcrumbList.
146
148
  */
147
149
  export function buildJsonLd(options: JsonLdOptions): JsonLdGraph {
148
- const { config, pagePath, pageTitle, baseUrl } = options;
150
+ const { config, pagePath, pageTitle, baseUrl, faqPairs } = options;
149
151
  const breadcrumbs = findBreadcrumbPath(config, pagePath, baseUrl, pageTitle);
150
152
 
151
153
  return {
@@ -166,6 +168,15 @@ export function buildJsonLd(options: JsonLdOptions): JsonLdGraph {
166
168
  item: item.url,
167
169
  })),
168
170
  },
171
+ ...(faqPairs && faqPairs.length
172
+ ? [{
173
+ '@type': 'FAQPage',
174
+ mainEntity: faqPairs.map((p) => ({
175
+ '@type': 'Question', name: p.question,
176
+ acceptedAnswer: { '@type': 'Answer', text: p.answer },
177
+ })),
178
+ }]
179
+ : []),
169
180
  ],
170
181
  };
171
182
  }
@@ -3,7 +3,7 @@
3
3
  // or — in non-ISR dev/tests — from URL params; this module owns the
4
4
  // rendering once those have resolved.
5
5
  import { Inter, JetBrains_Mono } from 'next/font/google';
6
- import { preload, preinit } from 'react-dom';
6
+ import { preinit } from 'react-dom';
7
7
  import fs from 'fs';
8
8
  import path from 'path';
9
9
  import { ThemeProvider } from '@/components/theme/ThemeProvider';
@@ -25,6 +25,8 @@ import type { BackgroundConfig, DocsConfig, FontConfig, LanguageCode } from '@/l
25
25
  import { LinkPrefixProvider } from '@/lib/link-prefix-context';
26
26
  import { ProjectSlugProvider } from '@/lib/project-slug-context';
27
27
  import { getAnalyticsScript } from '@/lib/analytics-script';
28
+ import { isConsentGatingEnabled, buildGatedScripts, getCmpScriptSrcs } from '@/lib/consent-gating';
29
+ import { ConsentGate } from '@/components/ConsentGate';
28
30
  import { AgentDirective } from './agent-directive';
29
31
  import { fetchCustomCss, fetchCustomJs } from '@/lib/r2-content';
30
32
  import { toHreflang } from '@/lib/language-utils';
@@ -429,6 +431,21 @@ export async function DocsChrome({
429
431
  ? getAnalyticsScript(resolvedProjectSlug)
430
432
  : null;
431
433
 
434
+ // integrations.cookies consent gating: when key+value are configured, all
435
+ // analytics/tracking scripts are withheld until the CMP writes
436
+ // localStorage[key] === value (ConsentGate injects them client-side).
437
+ const consentGating = isConsentGatingEnabled(config.integrations);
438
+ // CMP loaders (Osano/Termly) are NEVER gated — they ARE the consent banner.
439
+ const cmpScriptSrcs = process.env.NODE_ENV === 'production'
440
+ ? getCmpScriptSrcs(config.integrations)
441
+ : [];
442
+ // Preconnect origins are best-effort: getCmpScriptSrcs only returns
443
+ // regex-validated absolute https URLs (so new URL can't throw today), but
444
+ // guard anyway — a malformed preconnect must never 500 the whole render.
445
+ const cmpPreconnectOrigins = cmpScriptSrcs
446
+ .map((src) => { try { return new URL(src).origin; } catch { return null; } })
447
+ .filter((origin): origin is string => origin !== null);
448
+
432
449
  // Global banner: only emit the pre-paint no-flash guard for a dismissible
433
450
  // banner (a non-dismissible banner is never hidden, so the guard would risk
434
451
  // hiding a mandatory message via a stale dismissal hash). Trimmed content
@@ -437,26 +454,34 @@ export async function DocsChrome({
437
454
  const bannerNoFlash = !embed && !!bannerContent && config.banner?.dismissible === true && !!resolvedProjectSlug;
438
455
 
439
456
  // Font Awesome CSS uses preinit (not preload) so React 19 emits the
440
- // stylesheet link in <head> at SSR time. The previous approach used a
441
- // beforeInteractive Script that injected <link rel=stylesheet> at runtime —
442
- // that delayed @font-face parsing past the preloaded fonts' "few seconds"
443
- // grace window, producing the chrome console warning "preloaded but not
444
- // used within a few seconds." preinit closes the gap: the preloaded fonts
445
- // are matched to their @font-face declarations as soon as the stylesheet
446
- // parses, which now starts at HTML-parse time alongside the preloads.
457
+ // stylesheet link in <head> at SSR time. preinit (rather than a runtime
458
+ // beforeInteractive <Script>/<link> injection) is what gets the @font-face
459
+ // rules parsed early: the previous Script that ran document.createElement at
460
+ // parse time delayed @font-face parsing, so the actual woff2 fetch started
461
+ // late. preinit hoists the stylesheet at HTML-parse time instead.
447
462
  //
448
- // The font preloads stay on react-dom preload() so React 19 dedupes them
449
- // (JSX <link rel=preload> tags used to ship twice once from the JSX tree,
450
- // once from React's preload manager). The <noscript> fallback below uses
451
- // dangerouslySetInnerHTML for the same reason: a JSX <link rel=stylesheet>
452
- // inside noscript was being hoisted into <head> as a real stylesheet.
463
+ // The individual woff2 weights are deliberately NOT preloaded they load on
464
+ // demand when the stylesheet's @font-face rules are matched to the icons a
465
+ // page actually uses. The default docs icon weight is solid, so the previous
466
+ // unconditional fa-light-300 / fa-brands-400 preloads were almost never used
467
+ // and Chrome flagged them "preloaded but not used within a few seconds."
468
+ // The <noscript> fallback below uses dangerouslySetInnerHTML because a JSX
469
+ // <link rel=stylesheet> inside noscript was being hoisted into <head> as a
470
+ // real stylesheet by React 19.
453
471
  preinit(FA_CSS_HREF, { as: 'style' });
454
- preload('/_jd/fonts/fontawesome/webfonts/fa-light-300.woff2', { as: 'font', type: 'font/woff2', crossOrigin: 'anonymous' });
455
- preload('/_jd/fonts/fontawesome/webfonts/fa-brands-400.woff2', { as: 'font', type: 'font/woff2', crossOrigin: 'anonymous' });
456
472
 
457
473
  return (
458
474
  <html lang={toHreflang(lang)} dir={dir} suppressHydrationWarning data-scroll-behavior="smooth" data-scroll-locked="true">
459
475
  <head>
476
+ {/* CMP scripts must be first + synchronous: auto-blocking CMPs
477
+ (Osano/Termly) need to intercept later scripts. Never gated.
478
+ preconnect first: the sync fetch is render-blocking. */}
479
+ {cmpPreconnectOrigins.map((origin) => (
480
+ <link key={`pc-${origin}`} rel="preconnect" href={origin} />
481
+ ))}
482
+ {cmpScriptSrcs.map((src) => (
483
+ <script key={src} src={src} />
484
+ ))}
460
485
  {/*
461
486
  SSR scroll lock — prevents Chrome's same-tab cross-origin "preserve
462
487
  scroll" heuristic from scrolling #content-scroll-container on first
@@ -674,7 +699,7 @@ export async function DocsChrome({
674
699
  {customCss}
675
700
  </style>
676
701
  )}
677
- {process.env.NODE_ENV === 'production' && (config.integrations?.plausible?.domain || config.integrations?.plausible?.scriptUrl) && (
702
+ {!consentGating && process.env.NODE_ENV === 'production' && (config.integrations?.plausible?.domain || config.integrations?.plausible?.scriptUrl) && (
678
703
  <PlausibleScript
679
704
  domain={config.integrations.plausible.domain}
680
705
  server={config.integrations.plausible.server}
@@ -684,7 +709,7 @@ export async function DocsChrome({
684
709
  </head>
685
710
  <body className={fontClassName} data-theme={themeName || 'jam'} data-decoration={decoration || undefined} suppressHydrationWarning>
686
711
  {!embed && <AgentDirective hostAtDocs={!!config.hostAtDocs} />}
687
- {config.integrations?.gtm?.tagId && (
712
+ {!consentGating && config.integrations?.gtm?.tagId && (
688
713
  <ConditionalGTM gtmId={config.integrations.gtm.tagId} />
689
714
  )}
690
715
  <ThemeProvider
@@ -712,7 +737,7 @@ export async function DocsChrome({
712
737
  client" warning in `jamdesk dev`. Same pattern as the
713
738
  project-subdomain hydration suppressor in <head>.
714
739
  */}
715
- {process.env.NODE_ENV === 'production' && config.integrations?.crisp?.websiteId &&
740
+ {!consentGating && process.env.NODE_ENV === 'production' && config.integrations?.crisp?.websiteId &&
716
741
  /^[a-f0-9-]{36}$/.test(config.integrations.crisp.websiteId) && (
717
742
  <script
718
743
  dangerouslySetInnerHTML={{
@@ -720,15 +745,26 @@ export async function DocsChrome({
720
745
  }}
721
746
  />
722
747
  )}
723
- {process.env.NODE_ENV === 'production' && customJs && (
748
+ {!consentGating && process.env.NODE_ENV === 'production' && customJs && (
724
749
  <script dangerouslySetInnerHTML={{ __html: customJs }} />
725
750
  )}
726
- {process.env.NODE_ENV === 'production' && analyticsScript && (
751
+ {!consentGating && process.env.NODE_ENV === 'production' && analyticsScript && (
727
752
  <script dangerouslySetInnerHTML={{ __html: analyticsScript }} />
728
753
  )}
729
- {config.integrations?.ga4?.measurementId && (
754
+ {!consentGating && config.integrations?.ga4?.measurementId && (
730
755
  <ConditionalGA gaId={config.integrations.ga4.measurementId} />
731
756
  )}
757
+ {consentGating && process.env.NODE_ENV === 'production' && (
758
+ <ConsentGate
759
+ consentKey={config.integrations!.cookies!.key!}
760
+ consentValue={config.integrations!.cookies!.value!}
761
+ scripts={buildGatedScripts({
762
+ integrations: config.integrations,
763
+ customJs,
764
+ analyticsScript,
765
+ })}
766
+ />
767
+ )}
732
768
  <JdReadySentinel />
733
769
  </body>
734
770
  </html>
@@ -0,0 +1,5 @@
1
+ import type { DocsConfig } from './docs-types';
2
+
3
+ export function isLlmsTxtEnabled(config: DocsConfig): boolean {
4
+ return config.seo?.ai?.llmsTxt !== false;
5
+ }
@@ -0,0 +1,35 @@
1
+ import type { BuildWarning } from '../shared/status-reporter.js'; // vendored copy — NOT '../../shared'
2
+ import type { Logo } from './docs-types.js';
3
+
4
+ const MAX_LOGO_BYTES = 100 * 1024;
5
+ const MAX_REASONABLE_WIDTH = 720; // 2x the LOGO_MAX_WIDTH target
6
+
7
+ /**
8
+ * Collect the distinct image refs from a logo config (string or {light,dark}),
9
+ * in light-then-dark order with empties dropped.
10
+ *
11
+ * Used to SNAPSHOT the originals before the convertToWebp pass can rewrite them:
12
+ * an object {light,dark} logo shares its nested object across the enhancedConfig
13
+ * shallow spread, so a later in-place ".webp" rewrite mutates docsConfig.logo too —
14
+ * destroying the ".png"/".jpg" refs the always-on downscale needs. Reading from a
15
+ * pre-convert snapshot makes the downscale work for object logos, not just strings.
16
+ */
17
+ export function collectLogoRefs(logo: Logo | undefined): string[] {
18
+ const refs: string[] = [];
19
+ if (typeof logo === 'string') {
20
+ refs.push(logo);
21
+ } else if (logo && typeof logo === 'object') {
22
+ if (logo.light) refs.push(logo.light);
23
+ if (logo.dark) refs.push(logo.dark);
24
+ }
25
+ return [...new Set(refs.filter(Boolean))];
26
+ }
27
+
28
+ export function buildLogoWarning(logo: { path: string; width: number; height: number; bytes: number }): BuildWarning | null {
29
+ if (logo.bytes <= MAX_LOGO_BYTES && logo.width <= MAX_REASONABLE_WIDTH) return null;
30
+ return {
31
+ type: 'logo_oversized',
32
+ file: logo.path,
33
+ message: `Logo is ${logo.width}×${logo.height} (${Math.round(logo.bytes / 1024)} KB). It's auto-downscaled, but uploading a logo ≤ ${MAX_REASONABLE_WIDTH}px wide / ≤ 100 KB avoids shipping a large source.`,
34
+ };
35
+ }
@@ -0,0 +1,14 @@
1
+ // NOTE: lib/*.ts import the VENDORED shared copy at build-service/shared via
2
+ // '../shared/...js' (see other lib files). '../../shared' would resolve outside the
3
+ // build-service package and break tsc + the prod build.
4
+ import type { BuildWarning } from '../shared/status-reporter.js';
5
+
6
+ export function buildNoindexWarning(noindex: boolean): BuildWarning | null {
7
+ if (!noindex) return null;
8
+ return {
9
+ type: 'site_noindex',
10
+ file: 'docs.json',
11
+ message:
12
+ 'This site is set to noindex: robots.txt is "Disallow: /" and the sitemap is empty, so it will not be indexed by any search engines. AI crawlers are still served via llms.txt (set seo.ai.llmsTxt: false to disable). Remove "noindex" from seo.metatags.robots to allow search indexing.',
13
+ };
14
+ }
@@ -442,6 +442,32 @@ export async function sweepStaleLocaleLlmsTxt(
442
442
  return staleKeys;
443
443
  }
444
444
 
445
+ /**
446
+ * Delete a project's AI-context files: the root `llms.txt` + `llms-full.txt` and
447
+ * every per-locale `<locale>/llms.txt`. Called on every build where `seo.ai.llmsTxt`
448
+ * is false so a previously published llms.txt stops serving after a customer opts
449
+ * out — the build gate stops REGENERATING these files but otherwise leaves the
450
+ * already-uploaded copies in place. The deletes are idempotent (DeleteObject is a
451
+ * no-op-success on a missing key), so re-running on later disabled-llms builds is
452
+ * harmless and self-healing. Per-locale files are discovered and deleted by
453
+ * sweepStaleLocaleLlmsTxt with an EMPTY active-locale set (every known-language
454
+ * locale then counts as removable). Note the returned array lists the keys
455
+ * TARGETED, not necessarily ones that existed. Non-fatal by contract: callers treat
456
+ * a throw as a warning — a stale llms.txt is a cosmetic orphan, never build-failing.
457
+ */
458
+ export async function deleteLlmsTxtArtifacts(projectSlug: string): Promise<string[]> {
459
+ const localeKeys = await sweepStaleLocaleLlmsTxt(projectSlug, new Set());
460
+ const client = getR2S3Client();
461
+ const { bucketName } = getR2Config();
462
+ const rootKeys = [`${projectSlug}/llms.txt`, `${projectSlug}/llms-full.txt`];
463
+ // Independent keys — delete concurrently (matches deleteRemovedR2Objects' Promise.all).
464
+ // A rejection propagates so the caller's non-fatal try/catch logs it; see doc above.
465
+ await Promise.all(
466
+ rootKeys.map((key) => client.send(new DeleteObjectCommand({ Bucket: bucketName, Key: key }))),
467
+ );
468
+ return [...localeKeys, ...rootKeys];
469
+ }
470
+
445
471
  /**
446
472
  * Delete EVERY R2 object under the `{slug}/` prefix.
447
473
  *
@@ -63,6 +63,7 @@ import { getContentDir } from '@/lib/docs';
63
63
  import type { DocsConfig } from '@/lib/docs-types';
64
64
  import { buildSeoMetadata, generateAutoDescription, buildSiteTitle } from '@/lib/seo';
65
65
  import { buildJsonLd } from '@/lib/json-ld';
66
+ import { extractFaqPairs } from '@/lib/extract-faq';
66
67
  import {
67
68
  getContentLoader,
68
69
  isIsrMode,
@@ -337,11 +338,13 @@ export async function renderDocPage(input: RenderInput): Promise<ReactElement> {
337
338
  const rawContent = parsed.content;
338
339
 
339
340
  const baseUrl = resolveBaseUrl(requestHeaders, projectSlug, hostAtDocs);
341
+ const faqPairs = extractFaqPairs(rawContent);
340
342
  const jsonLd = buildJsonLd({
341
343
  config,
342
344
  pagePath,
343
345
  pageTitle: data.title || pagePath,
344
346
  baseUrl,
347
+ faqPairs,
345
348
  });
346
349
  const jsonLdScript = renderJsonLdScript(jsonLd);
347
350
 
@@ -615,6 +615,18 @@ function derivePageLocale(pagePath: string, languages?: LanguageConfig[]): strin
615
615
  * the full hreflang cluster, so language discovery is preserved.
616
616
  * @returns Partial<Metadata> to spread into generateMetadata return
617
617
  */
618
+
619
+ // Next.js Metadata.robots can be a string OR an object. When we must ALSO emit
620
+ // googlebot (object-only), a raw string robots directive would be lost — convert
621
+ // it to object form first so the general directive survives the merge.
622
+ function parseRobotsString(robots: string): { index: boolean; follow: boolean } {
623
+ const tokens = robots.toLowerCase().split(',').map((t) => t.trim());
624
+ return {
625
+ index: !tokens.includes('noindex'),
626
+ follow: !tokens.includes('nofollow'),
627
+ };
628
+ }
629
+
618
630
  export function buildSeoMetadata(
619
631
  config: DocsConfig,
620
632
  frontmatter: PageFrontmatter,
@@ -653,12 +665,14 @@ export function buildSeoMetadata(
653
665
 
654
666
  // 3. Googlebot (separate from robots)
655
667
  if (metatags.googlebot) {
656
- // Merge googlebot with existing robots config
657
- const existingRobots = typeof metadata.robots === 'object' ? metadata.robots : {};
658
- metadata.robots = {
659
- ...existingRobots,
660
- googleBot: metatags.googlebot,
661
- };
668
+ // Merge googlebot with existing robots config. When metadata.robots is a string
669
+ // (set above from metatags.robots), coerce it to object form first so the general
670
+ // directive (e.g. noindex) is not silently dropped by the spread.
671
+ const base =
672
+ typeof metadata.robots === 'string'
673
+ ? parseRobotsString(metadata.robots)
674
+ : (metadata.robots ?? {});
675
+ metadata.robots = { ...base, googleBot: metatags.googlebot };
662
676
  }
663
677
 
664
678
  // 4. Google site verification
@@ -36,6 +36,9 @@ export interface IntegrationsConfig {
36
36
  ga4?: { measurementId: string };
37
37
  gtm?: { tagId: string };
38
38
  plausible?: { domain?: string; server?: string; scriptUrl?: string };
39
+ osano?: { scriptSource: string };
40
+ termly?: { scriptSource: string };
41
+ cookies?: { key?: string; value?: string };
39
42
  [key: string]: unknown;
40
43
  }
41
44
 
@@ -0,0 +1,30 @@
1
+ import type { BuildWarning } from '../shared/status-reporter.js'; // vendored copy — NOT '../../shared'
2
+
3
+ const MIN_DESCRIPTION_CHARS = 110; // matches the Ahrefs "too short" floor used elsewhere
4
+
5
+ interface PageInfo {
6
+ path: string;
7
+ frontmatter: { title?: unknown; description?: unknown; hidden?: unknown };
8
+ content: string;
9
+ }
10
+
11
+ export function validatePageFrontmatter(pages: PageInfo[]): BuildWarning[] {
12
+ const warnings: BuildWarning[] = [];
13
+ for (const page of pages) {
14
+ const fm = page.frontmatter ?? {};
15
+ // Hidden pages are intentionally unlisted (not indexed, not in nav) — don't nag.
16
+ if (fm.hidden === true) continue;
17
+ const title = typeof fm.title === 'string' ? fm.title.trim() : '';
18
+ const description = typeof fm.description === 'string' ? fm.description.trim() : '';
19
+
20
+ if (!title) {
21
+ warnings.push({ type: 'page_missing_title', file: page.path, message: 'Page has no frontmatter title; navigation and llms.txt fall back to the file path.' });
22
+ }
23
+ if (!description) {
24
+ warnings.push({ type: 'page_missing_description', file: page.path, message: 'Page has no frontmatter description; add one for SEO and llms.txt.' });
25
+ } else if (description.length < MIN_DESCRIPTION_CHARS) {
26
+ warnings.push({ type: 'page_short_description', file: page.path, message: `Description is ${description.length} chars; aim for ${MIN_DESCRIPTION_CHARS}–160.` });
27
+ }
28
+ }
29
+ return warnings;
30
+ }
@@ -598,6 +598,16 @@
598
598
  "indexHiddenPages": {
599
599
  "type": "boolean",
600
600
  "description": "Include hidden pages in the sitemap for search engine indexing. Defaults to `false`"
601
+ },
602
+ "ai": {
603
+ "type": "object",
604
+ "additionalProperties": false,
605
+ "properties": {
606
+ "llmsTxt": {
607
+ "type": "boolean",
608
+ "description": "Generate llms.txt and llms-full.txt for AI crawlers. Default true. Set false to stop publishing AI-context files (independent of search noindex)."
609
+ }
610
+ }
601
611
  }
602
612
  },
603
613
  "additionalProperties": false,
@@ -1258,6 +1268,19 @@
1258
1268
  ],
1259
1269
  "additionalProperties": false
1260
1270
  },
1271
+ "termly": {
1272
+ "type": "object",
1273
+ "properties": {
1274
+ "scriptSource": {
1275
+ "type": "string",
1276
+ "pattern": "^https://app\\.termly\\.io/resource-blocker/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}(\\?autoBlock=(on|off))?$"
1277
+ }
1278
+ },
1279
+ "required": [
1280
+ "scriptSource"
1281
+ ],
1282
+ "additionalProperties": false
1283
+ },
1261
1284
  "pirsch": {
1262
1285
  "type": "object",
1263
1286
  "properties": {
@@ -1345,12 +1368,18 @@
1345
1368
  "type": "object",
1346
1369
  "properties": {
1347
1370
  "key": {
1348
- "type": "string"
1371
+ "type": "string",
1372
+ "minLength": 1
1349
1373
  },
1350
1374
  "value": {
1351
- "type": "string"
1375
+ "type": "string",
1376
+ "minLength": 1
1352
1377
  }
1353
1378
  },
1379
+ "required": [
1380
+ "key",
1381
+ "value"
1382
+ ],
1354
1383
  "additionalProperties": false
1355
1384
  }
1356
1385
  },
@@ -22,7 +22,29 @@ export interface ProgressUpdate {
22
22
  }
23
23
 
24
24
  /** Warning types that can occur during builds (non-blocking) */
25
- export type BuildWarningType = 'broken_link' | 'auto_migrate' | 'missing_asset' | 'missing_image' | 'missing_page' | 'missing_openapi_ref' | 'inline_code_on_api_page' | 'invalid_openapi_spec' | 'missing_snippet' | 'risky_expression' | 'missing_favicon' | 'missing_description' | 'missing_logo';
25
+ export const BUILD_WARNING_TYPES = [
26
+ 'broken_link',
27
+ 'auto_migrate',
28
+ 'missing_asset',
29
+ 'missing_image',
30
+ 'missing_page',
31
+ 'missing_openapi_ref',
32
+ 'inline_code_on_api_page',
33
+ 'invalid_openapi_spec',
34
+ 'missing_snippet',
35
+ 'risky_expression',
36
+ 'missing_favicon',
37
+ 'missing_description',
38
+ 'missing_logo',
39
+ // New (2026-06-16 docs-platform-review fixes):
40
+ 'site_noindex',
41
+ 'page_missing_title',
42
+ 'page_missing_description',
43
+ 'page_short_description',
44
+ 'logo_oversized',
45
+ ] as const;
46
+
47
+ export type BuildWarningType = (typeof BUILD_WARNING_TYPES)[number];
26
48
 
27
49
  /** Build warning structure */
28
50
  export interface BuildWarning {
@@ -55,6 +77,10 @@ export const SUGGESTION_TYPES: ReadonlySet<BuildWarningType> = new Set([
55
77
  'missing_favicon',
56
78
  'missing_description',
57
79
  'missing_logo',
80
+ 'site_noindex',
81
+ 'page_missing_description',
82
+ 'page_short_description',
83
+ 'logo_oversized',
58
84
  ]);
59
85
 
60
86
  /**
@@ -373,6 +373,9 @@
373
373
  "cpu": [
374
374
  "arm"
375
375
  ],
376
+ "libc": [
377
+ "glibc"
378
+ ],
376
379
  "license": "LGPL-3.0-or-later",
377
380
  "optional": true,
378
381
  "os": [
@@ -389,6 +392,9 @@
389
392
  "cpu": [
390
393
  "arm64"
391
394
  ],
395
+ "libc": [
396
+ "glibc"
397
+ ],
392
398
  "license": "LGPL-3.0-or-later",
393
399
  "optional": true,
394
400
  "os": [
@@ -405,6 +411,9 @@
405
411
  "cpu": [
406
412
  "ppc64"
407
413
  ],
414
+ "libc": [
415
+ "glibc"
416
+ ],
408
417
  "license": "LGPL-3.0-or-later",
409
418
  "optional": true,
410
419
  "os": [
@@ -421,6 +430,9 @@
421
430
  "cpu": [
422
431
  "riscv64"
423
432
  ],
433
+ "libc": [
434
+ "glibc"
435
+ ],
424
436
  "license": "LGPL-3.0-or-later",
425
437
  "optional": true,
426
438
  "os": [
@@ -437,6 +449,9 @@
437
449
  "cpu": [
438
450
  "s390x"
439
451
  ],
452
+ "libc": [
453
+ "glibc"
454
+ ],
440
455
  "license": "LGPL-3.0-or-later",
441
456
  "optional": true,
442
457
  "os": [
@@ -453,6 +468,9 @@
453
468
  "cpu": [
454
469
  "x64"
455
470
  ],
471
+ "libc": [
472
+ "glibc"
473
+ ],
456
474
  "license": "LGPL-3.0-or-later",
457
475
  "optional": true,
458
476
  "os": [
@@ -469,6 +487,9 @@
469
487
  "cpu": [
470
488
  "arm64"
471
489
  ],
490
+ "libc": [
491
+ "musl"
492
+ ],
472
493
  "license": "LGPL-3.0-or-later",
473
494
  "optional": true,
474
495
  "os": [
@@ -485,6 +506,9 @@
485
506
  "cpu": [
486
507
  "x64"
487
508
  ],
509
+ "libc": [
510
+ "musl"
511
+ ],
488
512
  "license": "LGPL-3.0-or-later",
489
513
  "optional": true,
490
514
  "os": [
@@ -501,6 +525,9 @@
501
525
  "cpu": [
502
526
  "arm"
503
527
  ],
528
+ "libc": [
529
+ "glibc"
530
+ ],
504
531
  "license": "Apache-2.0",
505
532
  "optional": true,
506
533
  "os": [
@@ -523,6 +550,9 @@
523
550
  "cpu": [
524
551
  "arm64"
525
552
  ],
553
+ "libc": [
554
+ "glibc"
555
+ ],
526
556
  "license": "Apache-2.0",
527
557
  "optional": true,
528
558
  "os": [
@@ -545,6 +575,9 @@
545
575
  "cpu": [
546
576
  "ppc64"
547
577
  ],
578
+ "libc": [
579
+ "glibc"
580
+ ],
548
581
  "license": "Apache-2.0",
549
582
  "optional": true,
550
583
  "os": [
@@ -567,6 +600,9 @@
567
600
  "cpu": [
568
601
  "riscv64"
569
602
  ],
603
+ "libc": [
604
+ "glibc"
605
+ ],
570
606
  "license": "Apache-2.0",
571
607
  "optional": true,
572
608
  "os": [
@@ -589,6 +625,9 @@
589
625
  "cpu": [
590
626
  "s390x"
591
627
  ],
628
+ "libc": [
629
+ "glibc"
630
+ ],
592
631
  "license": "Apache-2.0",
593
632
  "optional": true,
594
633
  "os": [
@@ -611,6 +650,9 @@
611
650
  "cpu": [
612
651
  "x64"
613
652
  ],
653
+ "libc": [
654
+ "glibc"
655
+ ],
614
656
  "license": "Apache-2.0",
615
657
  "optional": true,
616
658
  "os": [
@@ -633,6 +675,9 @@
633
675
  "cpu": [
634
676
  "arm64"
635
677
  ],
678
+ "libc": [
679
+ "musl"
680
+ ],
636
681
  "license": "Apache-2.0",
637
682
  "optional": true,
638
683
  "os": [
@@ -655,6 +700,9 @@
655
700
  "cpu": [
656
701
  "x64"
657
702
  ],
703
+ "libc": [
704
+ "musl"
705
+ ],
658
706
  "license": "Apache-2.0",
659
707
  "optional": true,
660
708
  "os": [
@@ -929,6 +977,9 @@
929
977
  "cpu": [
930
978
  "arm64"
931
979
  ],
980
+ "libc": [
981
+ "glibc"
982
+ ],
932
983
  "license": "MIT",
933
984
  "optional": true,
934
985
  "os": [
@@ -945,6 +996,9 @@
945
996
  "cpu": [
946
997
  "arm64"
947
998
  ],
999
+ "libc": [
1000
+ "musl"
1001
+ ],
948
1002
  "license": "MIT",
949
1003
  "optional": true,
950
1004
  "os": [
@@ -961,6 +1015,9 @@
961
1015
  "cpu": [
962
1016
  "x64"
963
1017
  ],
1018
+ "libc": [
1019
+ "glibc"
1020
+ ],
964
1021
  "license": "MIT",
965
1022
  "optional": true,
966
1023
  "os": [
@@ -977,6 +1034,9 @@
977
1034
  "cpu": [
978
1035
  "x64"
979
1036
  ],
1037
+ "libc": [
1038
+ "musl"
1039
+ ],
980
1040
  "license": "MIT",
981
1041
  "optional": true,
982
1042
  "os": [
@@ -1316,6 +1376,9 @@
1316
1376
  "cpu": [
1317
1377
  "arm64"
1318
1378
  ],
1379
+ "libc": [
1380
+ "glibc"
1381
+ ],
1319
1382
  "license": "MIT",
1320
1383
  "optional": true,
1321
1384
  "os": [
@@ -1332,6 +1395,9 @@
1332
1395
  "cpu": [
1333
1396
  "arm64"
1334
1397
  ],
1398
+ "libc": [
1399
+ "musl"
1400
+ ],
1335
1401
  "license": "MIT",
1336
1402
  "optional": true,
1337
1403
  "os": [
@@ -1348,6 +1414,9 @@
1348
1414
  "cpu": [
1349
1415
  "x64"
1350
1416
  ],
1417
+ "libc": [
1418
+ "glibc"
1419
+ ],
1351
1420
  "license": "MIT",
1352
1421
  "optional": true,
1353
1422
  "os": [
@@ -1364,6 +1433,9 @@
1364
1433
  "cpu": [
1365
1434
  "x64"
1366
1435
  ],
1436
+ "libc": [
1437
+ "musl"
1438
+ ],
1367
1439
  "license": "MIT",
1368
1440
  "optional": true,
1369
1441
  "os": [
@@ -2852,9 +2924,9 @@
2852
2924
  }
2853
2925
  },
2854
2926
  "node_modules/dompurify": {
2855
- "version": "3.4.10",
2856
- "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.10.tgz",
2857
- "integrity": "sha512-0xzNv0e7oYC6yyuOGZIABPM4qtg3QxLFniDNPP4ZP90wR8Yq3zgwpRbrNiT4N3IKqDbbYFEJLV+JWEs19aZ//w==",
2927
+ "version": "3.4.11",
2928
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
2929
+ "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
2858
2930
  "license": "(MPL-2.0 OR Apache-2.0)",
2859
2931
  "optionalDependencies": {
2860
2932
  "@types/trusted-types": "^2.0.7"
@@ -2867,9 +2939,9 @@
2867
2939
  "license": "MIT"
2868
2940
  },
2869
2941
  "node_modules/electron-to-chromium": {
2870
- "version": "1.5.373",
2871
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.373.tgz",
2872
- "integrity": "sha512-G2Hym8JIf/QreuseqkDibgH8Ci8KfJzqGDKdakbhSx9UltwRBH2cBLAWU/lBX0sCdv0TlhyxQyDCnSfxgMWsjA==",
2942
+ "version": "1.5.375",
2943
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.375.tgz",
2944
+ "integrity": "sha512-ZWP5eB4BVPW/ZYo9252hQZHZ5XavtsTgpbhcmMmRwymavC5AsLWQWBPaKMeNd2LW0KGby5HPXvj7+sr4ta5j/Q==",
2873
2945
  "license": "ISC"
2874
2946
  },
2875
2947
  "node_modules/enhanced-resolve": {
@@ -3865,6 +3937,9 @@
3865
3937
  "cpu": [
3866
3938
  "arm64"
3867
3939
  ],
3940
+ "libc": [
3941
+ "glibc"
3942
+ ],
3868
3943
  "license": "MPL-2.0",
3869
3944
  "optional": true,
3870
3945
  "os": [
@@ -3885,6 +3960,9 @@
3885
3960
  "cpu": [
3886
3961
  "arm64"
3887
3962
  ],
3963
+ "libc": [
3964
+ "musl"
3965
+ ],
3888
3966
  "license": "MPL-2.0",
3889
3967
  "optional": true,
3890
3968
  "os": [
@@ -3905,6 +3983,9 @@
3905
3983
  "cpu": [
3906
3984
  "x64"
3907
3985
  ],
3986
+ "libc": [
3987
+ "glibc"
3988
+ ],
3908
3989
  "license": "MPL-2.0",
3909
3990
  "optional": true,
3910
3991
  "os": [
@@ -3925,6 +4006,9 @@
3925
4006
  "cpu": [
3926
4007
  "x64"
3927
4008
  ],
4009
+ "libc": [
4010
+ "musl"
4011
+ ],
3928
4012
  "license": "MPL-2.0",
3929
4013
  "optional": true,
3930
4014
  "os": [