jamdesk 1.1.190 → 1.1.192

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.
@@ -1 +1 @@
1
- {"version":3,"file":"inert-config-keys.d.ts","sourceRoot":"","sources":["../../src/lib/inert-config-keys.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAYH,MAAM,WAAW,eAAe;IAAG,GAAG,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;CAAE;AAgBlE,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,OAAO,GAAG,eAAe,EAAE,CA4BtE"}
1
+ {"version":3,"file":"inert-config-keys.d.ts","sourceRoot":"","sources":["../../src/lib/inert-config-keys.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAYH,MAAM,WAAW,eAAe;IAAG,GAAG,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;CAAE;AAsClE,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,OAAO,GAAG,eAAe,EAAE,CA8CtE"}
@@ -10,6 +10,25 @@ const ASYNC = 'AsyncAPI specs are not yet rendered — this key has no effect. '
10
10
  'Track: github.com/jamdesk/jamdesk-docs/issues/10';
11
11
  const MDX_SERVER = 'Only the first entry of api.mdx.server is used; the rest have no effect. ' +
12
12
  'Multi-server selection is available for openapi:-backed pages.';
13
+ const OPENAPI_INERT_SHAPE = 'This openapi key generates no pages. Did you mean ' +
14
+ '"openapi": { "source": "<spec>", "generate": true }? A bare string, an ' +
15
+ 'object without `generate: true`, or one missing `source`, is read by ' +
16
+ 'nothing — every operation still needs its own committed .mdx. ' +
17
+ 'Track: github.com/jamdesk/jamdesk-docs/issues/9';
18
+ const OPENAPI_WRONG_LEVEL = 'Page generation from a spec is supported on navigation.tabs only, so this ' +
19
+ 'openapi key has no effect. Move it onto the tab that owns these pages and ' +
20
+ 'use { "source": "<spec>", "generate": true }. ' +
21
+ 'Track: github.com/jamdesk/jamdesk-docs/issues/9';
22
+ /** Mirrors `generatedSpecSource` in lib/openapi/expand-spec-pages.ts — the one
23
+ * shape that actually materialises pages. Kept in step with it deliberately:
24
+ * a hint that disagrees with the expander is worse than no hint. */
25
+ function generatesPages(openapi) {
26
+ if (!isNode(openapi) || Array.isArray(openapi))
27
+ return false;
28
+ if (openapi.generate !== true)
29
+ return false;
30
+ return typeof openapi.source === 'string' && openapi.source.trim() !== '';
31
+ }
13
32
  const CHILD_KEYS = ['tabs', 'anchors', 'groups', 'pages', 'dropdowns', 'products', 'menu'];
14
33
  export function findInertConfigKeys(config) {
15
34
  const out = [];
@@ -23,23 +42,41 @@ export function findInertConfigKeys(config) {
23
42
  out.push({ key: 'api.mdx.server', message: MDX_SERVER });
24
43
  }
25
44
  const seen = new Set(); // configs are author-written; refuse to trust them not to cycle
26
- const walk = (nodes, path) => {
45
+ // `atTabLevel` marks the direct children of top-level navigation.tabs — the
46
+ // only place the expander looks. The index is in the path because a config
47
+ // with eight tabs got told `navigation.tabs.asyncapi` and had to find which.
48
+ const walk = (nodes, path, atTabLevel) => {
27
49
  if (!Array.isArray(nodes))
28
50
  return;
29
- for (const n of nodes) {
51
+ nodes.forEach((n, i) => {
30
52
  if (!isNode(n) || seen.has(n))
31
- continue;
53
+ return;
32
54
  seen.add(n);
55
+ const here = `${path}[${i}]`;
33
56
  if (n.asyncapi)
34
- out.push({ key: `${path}.asyncapi`, message: ASYNC });
35
- // Deliberately NOT warning on `n.openapi` Task 17 implements it.
57
+ out.push({ key: `${here}.asyncapi`, message: ASYNC });
58
+ // Nav-level `openapi` is read by the page expander and by nothing else:
59
+ // navigation-resolver never looks at it, and resolveOpenApiSpec takes an
60
+ // explicit ref off page frontmatter or api.openapi. So any shape the
61
+ // expander rejects is silently doing nothing, which is exactly what #9
62
+ // was reported for — the config validated clean and produced no pages.
63
+ // Off a tab, EVERY shape is inert — including the correct one, which is
64
+ // the trap worth naming: an author who copies the documented
65
+ // `{source, generate: true}` onto an anchor gets a clean validate and no
66
+ // pages. So the shape only earns a pass at tab level.
67
+ if (n.openapi && (!atTabLevel || !generatesPages(n.openapi))) {
68
+ out.push({
69
+ key: `${here}.openapi`,
70
+ message: atTabLevel ? OPENAPI_INERT_SHAPE : OPENAPI_WRONG_LEVEL,
71
+ });
72
+ }
36
73
  for (const k of CHILD_KEYS)
37
- walk(n[k], `${path}.${k}`);
38
- }
74
+ walk(n[k], `${here}.${k}`, false);
75
+ });
39
76
  };
40
77
  const nav = isNode(config.navigation) ? config.navigation : {};
41
78
  for (const k of CHILD_KEYS)
42
- walk(nav[k], `navigation.${k}`);
79
+ walk(nav[k], `navigation.${k}`, k === 'tabs');
43
80
  return out;
44
81
  }
45
82
  //# sourceMappingURL=inert-config-keys.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"inert-config-keys.js","sourceRoot":"","sources":["../../src/lib/inert-config-keys.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAeH,MAAM,MAAM,GAAG,CAAC,CAAU,EAAa,EAAE,CAAC,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,KAAK,IAAI,CAAC;AAE9E,MAAM,KAAK,GACT,gEAAgE;IAChE,0EAA0E;IAC1E,kDAAkD,CAAC;AAErD,MAAM,UAAU,GACd,2EAA2E;IAC3E,gEAAgE,CAAC;AAEnE,MAAM,UAAU,GAAG,CAAC,MAAM,EAAE,SAAS,EAAE,QAAQ,EAAE,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,CAAU,CAAC;AAEpG,MAAM,UAAU,mBAAmB,CAAC,MAAe;IACjD,MAAM,GAAG,GAAsB,EAAE,CAAC;IAClC,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC;QAAE,OAAO,GAAG,CAAC;IAEhC,MAAM,GAAG,GAAG,MAAM,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,SAAS,CAAC;IACxD,IAAI,GAAG,EAAE,QAAQ;QAAE,GAAG,CAAC,IAAI,CAAC,EAAE,GAAG,EAAE,cAAc,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC,CAAC;IAErE,MAAM,GAAG,GAAG,GAAG,IAAI,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,SAAS,CAAC;IACzD,IAAI,KAAK,CAAC,OAAO,CAAC,GAAG,EAAE,MAAM,CAAC,IAAI,GAAG,CAAC,MAAM,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACxD,GAAG,CAAC,IAAI,CAAC,EAAE,GAAG,EAAE,gBAAgB,EAAE,OAAO,EAAE,UAAU,EAAE,CAAC,CAAC;IAC3D,CAAC;IAED,MAAM,IAAI,GAAG,IAAI,GAAG,EAAQ,CAAC,CAAG,gEAAgE;IAChG,MAAM,IAAI,GAAG,CAAC,KAAc,EAAE,IAAY,EAAQ,EAAE;QAClD,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC;YAAE,OAAO;QAClC,KAAK,MAAM,CAAC,IAAI,KAAK,EAAE,CAAC;YACtB,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;gBAAE,SAAS;YACxC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;YACZ,IAAI,CAAC,CAAC,QAAQ;gBAAE,GAAG,CAAC,IAAI,CAAC,EAAE,GAAG,EAAE,GAAG,IAAI,WAAW,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC,CAAC;YACtE,mEAAmE;YACnE,KAAK,MAAM,CAAC,IAAI,UAAU;gBAAE,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,GAAG,IAAI,IAAI,CAAC,EAAE,CAAC,CAAC;QACzD,CAAC;IACH,CAAC,CAAC;IAEF,MAAM,GAAG,GAAG,MAAM,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC,CAAC,EAAE,CAAC;IAC/D,KAAK,MAAM,CAAC,IAAI,UAAU;QAAE,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,cAAc,CAAC,EAAE,CAAC,CAAC;IAE5D,OAAO,GAAG,CAAC;AACb,CAAC"}
1
+ {"version":3,"file":"inert-config-keys.js","sourceRoot":"","sources":["../../src/lib/inert-config-keys.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jamdesk",
3
- "version": "1.1.190",
3
+ "version": "1.1.192",
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",
@@ -112,7 +112,7 @@ export async function GET(
112
112
  // Arg order is load-bearing: `withDirective` is the agent-visible body the
113
113
  // footer is appended to; `raw` is the unfiltered source the helper reads
114
114
  // frontmatter from (visibility filtering never touches the leading YAML block).
115
- const body = await withApiSpecsHint(
115
+ const hinted = await withApiSpecsHint(
116
116
  withDirective,
117
117
  raw,
118
118
  project,
@@ -120,6 +120,12 @@ export async function GET(
120
120
  request.headers,
121
121
  hostAtDocs,
122
122
  );
123
+ // Exactly one trailing newline. Text files end in one, and both paths into
124
+ // this body drop it: the footer string has no terminator, and the no-footer
125
+ // path returns whatever the source MDX happened to end with. 107 of snag's
126
+ // 115 exports shipped without it, so anything appending to or concatenating
127
+ // these files ran the last line of one into the next.
128
+ const body = `${hinted.replace(/\s+$/, '')}\n`;
123
129
 
124
130
  // Two paths reach this handler via proxy rewrite:
125
131
  // (a) `.md` URL: unique CDN cache key, safe to share.
@@ -10,8 +10,29 @@ import type { DocsConfig } from '@/lib/docs-types';
10
10
  // chrome moved back to root layout, so this needs to match (was previously a
11
11
  // bare-html shell from the route-group world, which produced nested <html>
12
12
  // inside <main> when notFound() bubbled from app/[[...slug]]/page.tsx).
13
+ // A 404 still gets crawled, linked and unfurled in chat, so it needs the same
14
+ // three description surfaces every other page has. It previously set only a
15
+ // title, which left og:description and twitter:description absent — an unfurl
16
+ // of a dead docs link rendered as a bare title with no explanation.
17
+ // Static because notFound() can fire before any project config is resolved
18
+ // (placeholder shells, unknown tenants); nothing here may depend on a fetch.
19
+ const NOT_FOUND_DESCRIPTION =
20
+ 'This page does not exist. It may have been moved or renamed — use search or the navigation to find what you are looking for.';
21
+
13
22
  export const metadata = {
14
23
  title: 'Page not found',
24
+ description: NOT_FOUND_DESCRIPTION,
25
+ // Keep a 404 out of the index regardless of what the site-wide default says.
26
+ robots: { index: false, follow: true },
27
+ openGraph: {
28
+ title: 'Page not found',
29
+ description: NOT_FOUND_DESCRIPTION,
30
+ },
31
+ twitter: {
32
+ card: 'summary',
33
+ title: 'Page not found',
34
+ description: NOT_FOUND_DESCRIPTION,
35
+ },
15
36
  };
16
37
 
17
38
  export default async function NotFound() {
@@ -1,5 +1,6 @@
1
1
  'use client';
2
2
 
3
+ import type React from 'react';
3
4
  import ReactMarkdown from 'react-markdown';
4
5
  import remarkGfm from 'remark-gfm';
5
6
 
@@ -26,12 +27,17 @@ import remarkGfm from 'remark-gfm';
26
27
  export function ApiMarkdown({
27
28
  children,
28
29
  className,
30
+ style,
29
31
  }: {
30
32
  children: string;
31
33
  className: string;
34
+ /** The playground panel styles exclusively with inline CSS variables (see
35
+ * PlaygroundParamForm's header), so it has no class to pass. Optional so
36
+ * every other call site stays untouched. */
37
+ style?: React.CSSProperties;
32
38
  }) {
33
39
  return (
34
- <div className={className}>
40
+ <div className={className} style={style}>
35
41
  <ReactMarkdown remarkPlugins={[[remarkGfm, { singleTilde: false }]]}>
36
42
  {children}
37
43
  </ReactMarkdown>
@@ -9,6 +9,8 @@ import { useShikiHighlightMultiple } from '@/hooks/useShikiHighlight';
9
9
  import { preloadHighlighter } from '@/lib/shiki-client';
10
10
  import { ApiMarkdown, API_DESCRIPTION_PROSE } from './ApiMarkdown';
11
11
  import { resolveServerUrl } from '@/lib/openapi/resolve-server-url';
12
+
13
+
12
14
  import { getResponseExample } from '@/lib/openapi/response-examples';
13
15
  // Icons use Font Awesome CSS classes for lightweight rendering
14
16
  import type {
@@ -22,6 +24,12 @@ import type {
22
24
  ServerInfo,
23
25
  } from '@/lib/openapi/types';
24
26
 
27
+ /** One key for the whole site: a reader who picks "Sandbox" means it for every
28
+ * endpoint they then read, not just the one they were on. Session-scoped so it
29
+ * never outlives the tab — a persisted prod/sandbox choice leaking into a
30
+ * later visit is a worse surprise than re-picking. */
31
+ const SERVER_CHOICE_KEY = 'jd:openapi:server-url';
32
+
25
33
  // Preload Shiki highlighter on module load
26
34
  if (typeof window !== 'undefined') {
27
35
  void preloadHighlighter();
@@ -846,6 +854,43 @@ export function OpenApiEndpoint({
846
854
  // than one (see the <select> below); index 0 otherwise, matching the
847
855
  // previous hardcoded servers[0] behaviour for every single-server site.
848
856
  const [serverIndex, setServerIndex] = useState(0);
857
+ // The choice is a reading preference, not page state. This useState is
858
+ // component-local and the router remounts it on every client-side
859
+ // navigation, so picking "Sandbox" and clicking the next endpoint silently
860
+ // put the reader back on servers[0] — with copy-paste examples to match.
861
+ //
862
+ // The stored value is the server URL, not the index: sibling endpoints can
863
+ // declare different server lists, and index 2 on one page is a different
864
+ // host on the next. A URL the current page does not offer simply loses,
865
+ // leaving the default.
866
+ //
867
+ // Hydrated in an effect rather than read during render: sessionStorage is
868
+ // not available on the server, so seeding useState from it would make the
869
+ // first client render disagree with the SSR HTML.
870
+ useEffect(() => {
871
+ if (servers.length < 2) return;
872
+ try {
873
+ const stored = window.sessionStorage.getItem(SERVER_CHOICE_KEY);
874
+ if (!stored) return;
875
+ const i = servers.findIndex((s) => resolveServerUrl(s) === stored);
876
+ if (i > 0) setServerIndex(i);
877
+ } catch {
878
+ // Private mode, or storage disabled — the default is a fine outcome.
879
+ }
880
+ }, [servers]);
881
+
882
+ const chooseServer = (i: number) => {
883
+ setServerIndex(i);
884
+ // resolveServerUrl is total over `undefined` and returns undefined for a
885
+ // server whose url is missing — nothing worth storing either way.
886
+ const picked = resolveServerUrl(servers[i]);
887
+ if (!picked) return;
888
+ try {
889
+ window.sessionStorage.setItem(SERVER_CHOICE_KEY, picked);
890
+ } catch {
891
+ // Non-fatal: the selection still applies to this page.
892
+ }
893
+ };
849
894
  const baseUrl = resolveServerUrl(servers[serverIndex]);
850
895
  // codeExamplesByServer is only ever populated for multi-server specs; the
851
896
  // ?? falls every existing caller (incl. inline-MDX) back to codeExamples.
@@ -909,7 +954,7 @@ export function OpenApiEndpoint({
909
954
  <select
910
955
  aria-label="API server"
911
956
  value={serverIndex}
912
- onChange={(e) => setServerIndex(Number(e.target.value))}
957
+ onChange={(e) => chooseServer(Number(e.target.value))}
913
958
  className="min-w-0 max-w-full px-2 py-1 rounded-md border border-[var(--color-border)] bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] text-xs appearance-none cursor-pointer"
914
959
  >
915
960
  {servers.map((s, i) => (
@@ -815,9 +815,15 @@ export function Sidebar({ config, layout = 'header-logo', tabsPosition: tabsPosi
815
815
 
816
816
  const showTopDivider = resolvedNav.externalAnchors.length > 0;
817
817
  const hasTopTabsBar = getTabsFromConfig(config, pathname || undefined).length > 1 && !showTabsInSidebar;
818
+ // `lg:max-h-full` caps the sidebar at the height of the layout row that holds
819
+ // it — LayoutWrapper has already subtracted the banner, the header and the
820
+ // tabs bar from that row. Without the cap, `self-start` sizes the <aside> to
821
+ // its content and the theme's scroller has to guess those heights off 100vh;
822
+ // the guess (57px header) was 7px short of the 64px the header actually
823
+ // renders, so the last nav item sat under the row's `lg:overflow-hidden`.
818
824
  const desktopSidebarClasses = layout === 'sidebar-logo'
819
825
  ? 'lg:fixed lg:top-[var(--jd-banner-height,0px)] lg:left-0 lg:bottom-0 lg:z-40'
820
- : 'lg:sticky lg:self-start lg:z-30 lg:flex-shrink-0';
826
+ : 'lg:sticky lg:self-start lg:max-h-full lg:z-30 lg:flex-shrink-0';
821
827
 
822
828
  return (
823
829
  <>
@@ -22,6 +22,28 @@ const MDX_SERVER =
22
22
  'Only the first entry of api.mdx.server is used; the rest have no effect. ' +
23
23
  'Multi-server selection is available for openapi:-backed pages.';
24
24
 
25
+ const OPENAPI_INERT_SHAPE =
26
+ 'This openapi key generates no pages. Did you mean ' +
27
+ '"openapi": { "source": "<spec>", "generate": true }? A bare string, an ' +
28
+ 'object without `generate: true`, or one missing `source`, is read by ' +
29
+ 'nothing — every operation still needs its own committed .mdx. ' +
30
+ 'Track: github.com/jamdesk/jamdesk-docs/issues/9';
31
+
32
+ const OPENAPI_WRONG_LEVEL =
33
+ 'Page generation from a spec is supported on navigation.tabs only, so this ' +
34
+ 'openapi key has no effect. Move it onto the tab that owns these pages and ' +
35
+ 'use { "source": "<spec>", "generate": true }. ' +
36
+ 'Track: github.com/jamdesk/jamdesk-docs/issues/9';
37
+
38
+ /** Mirrors `generatedSpecSource` in lib/openapi/expand-spec-pages.ts — the one
39
+ * shape that actually materialises pages. Kept in step with it deliberately:
40
+ * a hint that disagrees with the expander is worse than no hint. */
41
+ function generatesPages(openapi: unknown): boolean {
42
+ if (!isNode(openapi) || Array.isArray(openapi)) return false;
43
+ if (openapi.generate !== true) return false;
44
+ return typeof openapi.source === 'string' && openapi.source.trim() !== '';
45
+ }
46
+
25
47
  const CHILD_KEYS = ['tabs', 'anchors', 'groups', 'pages', 'dropdowns', 'products', 'menu'] as const;
26
48
 
27
49
  export function findInertConfigKeys(config: unknown): InertKeyWarning[] {
@@ -37,19 +59,37 @@ export function findInertConfigKeys(config: unknown): InertKeyWarning[] {
37
59
  }
38
60
 
39
61
  const seen = new Set<Node>(); // configs are author-written; refuse to trust them not to cycle
40
- const walk = (nodes: unknown, path: string): void => {
62
+ // `atTabLevel` marks the direct children of top-level navigation.tabs — the
63
+ // only place the expander looks. The index is in the path because a config
64
+ // with eight tabs got told `navigation.tabs.asyncapi` and had to find which.
65
+ const walk = (nodes: unknown, path: string, atTabLevel: boolean): void => {
41
66
  if (!Array.isArray(nodes)) return;
42
- for (const n of nodes) {
43
- if (!isNode(n) || seen.has(n)) continue;
67
+ nodes.forEach((n, i) => {
68
+ if (!isNode(n) || seen.has(n)) return;
44
69
  seen.add(n);
45
- if (n.asyncapi) out.push({ key: `${path}.asyncapi`, message: ASYNC });
46
- // Deliberately NOT warning on `n.openapi` Task 17 implements it.
47
- for (const k of CHILD_KEYS) walk(n[k], `${path}.${k}`);
48
- }
70
+ const here = `${path}[${i}]`;
71
+ if (n.asyncapi) out.push({ key: `${here}.asyncapi`, message: ASYNC });
72
+ // Nav-level `openapi` is read by the page expander and by nothing else:
73
+ // navigation-resolver never looks at it, and resolveOpenApiSpec takes an
74
+ // explicit ref off page frontmatter or api.openapi. So any shape the
75
+ // expander rejects is silently doing nothing, which is exactly what #9
76
+ // was reported for — the config validated clean and produced no pages.
77
+ // Off a tab, EVERY shape is inert — including the correct one, which is
78
+ // the trap worth naming: an author who copies the documented
79
+ // `{source, generate: true}` onto an anchor gets a clean validate and no
80
+ // pages. So the shape only earns a pass at tab level.
81
+ if (n.openapi && (!atTabLevel || !generatesPages(n.openapi))) {
82
+ out.push({
83
+ key: `${here}.openapi`,
84
+ message: atTabLevel ? OPENAPI_INERT_SHAPE : OPENAPI_WRONG_LEVEL,
85
+ });
86
+ }
87
+ for (const k of CHILD_KEYS) walk(n[k], `${here}.${k}`, false);
88
+ });
49
89
  };
50
90
 
51
91
  const nav = isNode(config.navigation) ? config.navigation : {};
52
- for (const k of CHILD_KEYS) walk(nav[k], `navigation.${k}`);
92
+ for (const k of CHILD_KEYS) walk(nav[k], `navigation.${k}`, k === 'tabs');
53
93
 
54
94
  return out;
55
95
  }
@@ -180,7 +180,15 @@ function describeScheme(name: string, scopes: unknown[], schemes: Record<string,
180
180
  * one suffices); each entry is a set of schemes that all apply together. An
181
181
  * operation's own `security` overrides the document default, and an explicit
182
182
  * empty array means this endpoint takes no auth — which is worth stating,
183
- * not worth omitting. Returns [] when neither level declares anything. */
183
+ * not worth omitting.
184
+ *
185
+ * ABSENT at both levels means the same thing as an explicit `[]`: OpenAPI
186
+ * defines an operation with no `security` and no document default as carrying
187
+ * no security requirement. This used to return [] instead, so six snag
188
+ * operations shipped `.md` exports and llms-full entries with no
189
+ * `## Authentication` section at all while their `security: []` siblings said
190
+ * "No authentication required." — the same fact, told two different ways, and
191
+ * an agent reading the silent one learns nothing about how to call it. */
184
192
  function securityLines(
185
193
  operationSecurity: unknown,
186
194
  specSecurity: unknown,
@@ -188,8 +196,7 @@ function securityLines(
188
196
  ): string[] {
189
197
  const security = Array.isArray(operationSecurity)
190
198
  ? operationSecurity
191
- : (Array.isArray(specSecurity) ? specSecurity : null);
192
- if (security === null) return [];
199
+ : (Array.isArray(specSecurity) ? specSecurity : []);
193
200
 
194
201
  const out: string[] = ['## Authentication', ''];
195
202
  if (security.length === 0) {
@@ -223,6 +230,29 @@ function resolveServerTemplate(server: { url?: string; variables?: Record<string
223
230
  return String(server.url).replace(/\{(\w+)\}/g, (_, key: string) => server.variables?.[key]?.default ?? `{${key}}`);
224
231
  }
225
232
 
233
+ /** `schemaLines` bounded to `maxLines`, with the notice INSIDE the block.
234
+ *
235
+ * The cap used to be applied once to the whole document, after every section
236
+ * had been appended. A single huge schema therefore consumed the budget and
237
+ * silently deleted every section that came after it — spec-declared
238
+ * `## Response 403` sections vanished from `.md` exports and llms-full while
239
+ * the rendered HTML page, which does not go through here, still showed them.
240
+ * Agents reading the export concluded those endpoints could not fail.
241
+ *
242
+ * Capping per schema keeps the same guard against the wide-DAG blowup the
243
+ * DEFAULT_MAX_LINES comment describes (each block is bounded, so the document
244
+ * is bounded by section count x maxLines) while letting every section the spec
245
+ * declares actually appear. */
246
+ function cappedSchemaLines(
247
+ schema: unknown,
248
+ opts: SchemaLinesOptions,
249
+ maxLines: number,
250
+ ): string[] {
251
+ const lines = schemaLines(schema, opts);
252
+ if (lines.length <= maxLines) return lines;
253
+ return [...lines.slice(0, maxLines), '', `… schema truncated at ${maxLines} lines.`];
254
+ }
255
+
226
256
  /** Flatten a dereferenced OpenAPI operation into markdown: method/path,
227
257
  * parameters grouped by location, request body and one `##` section per
228
258
  * response status code. Every heading is a real `##` line — never bold —
@@ -283,7 +313,7 @@ export function endpointToMarkdown(operation: unknown, options: EndpointToMarkdo
283
313
  for (const [mime, media] of Object.entries(requestBody.content)) {
284
314
  out.push(`- Content type: \`${mime}\``);
285
315
  if (media && media.schema) {
286
- out.push(...schemaLines(media.schema, { depth: 1, maxDepth, seen: new Set(), rootLabel: 'request body' }));
316
+ out.push(...cappedSchemaLines(media.schema, { depth: 1, maxDepth, seen: new Set(), rootLabel: 'request body' }, maxLines));
287
317
  }
288
318
  }
289
319
  out.push('');
@@ -300,7 +330,7 @@ export function endpointToMarkdown(operation: unknown, options: EndpointToMarkdo
300
330
  const content = response.content || {};
301
331
  for (const media of Object.values(content)) {
302
332
  if (media && media.schema) {
303
- out.push(...schemaLines(media.schema, { depth: 1, maxDepth, seen: new Set(), rootLabel: 'response' }));
333
+ out.push(...cappedSchemaLines(media.schema, { depth: 1, maxDepth, seen: new Set(), rootLabel: 'response' }, maxLines));
304
334
  }
305
335
  }
306
336
  // Blank line per status, not once after the loop: without it the next
@@ -309,10 +339,6 @@ export function endpointToMarkdown(operation: unknown, options: EndpointToMarkdo
309
339
  }
310
340
  }
311
341
 
312
- if (out.length > maxLines) {
313
- out.length = maxLines;
314
- out.push('', `… schema truncated at ${maxLines} lines.`);
315
- }
316
342
  return out.join('\n').replace(/\n{3,}/g, '\n\n').trim();
317
343
  }
318
344
 
@@ -43,9 +43,38 @@ export function stripMarkdownForMeta(text: string): string {
43
43
  s = s.replace(/`([^`]+)`/g, "$1"); // inline code -> its text
44
44
  s = s.replace(/!\[[^\]]*\]\([^)]*\)/g, " "); // images
45
45
  s = s.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1"); // links -> link text
46
- s = s.replace(/^[ \t]*[#>*\-+]+[ \t]*/gm, ""); // heading/quote/bullet markers
46
+ // Heading/quote/bullet markers. Asterisks are handled apart from the rest:
47
+ // `**Bold** at line start` is emphasis, not a bullet, and folding it into the
48
+ // marker class ate the OPENING run and left the closing `**` stranded in the
49
+ // output. A bullet needs whitespace after its marker; a rule is a run of
50
+ // three or more alone on the line.
51
+ s = s.replace(/^[ \t]*[#>\-+]+[ \t]*/gm, "");
52
+ s = s.replace(/^[ \t]*\*[ \t]+/gm, ""); // asterisk bullet: marker THEN space
53
+ s = s.replace(/^[ \t]*\*{3,}[ \t]*$/gm, " "); // horizontal rule
47
54
  s = s.replace(/^[ \t]*\d+\.[ \t]+/gm, ""); // ordered-list markers
48
- s = s.replace(/[*~]+/g, ""); // emphasis runs (asterisk/tilde)
55
+ // GFM tables. The delimiter row is pure punctuation, and a description that
56
+ // opens with a table otherwise ships to <meta> as mostly pipes.
57
+ s = s.replace(/^[ \t]*\|?[ \t]*:?-{2,}:?[ \t]*(?:\|[ \t]*:?-{2,}:?[ \t]*)+\|?[ \t]*$/gm, " ");
58
+ s = s.replace(/^[ \t]*\|(.*)\|[ \t]*$/gm, (_m, row: string) =>
59
+ row.split("|").map((c) => c.trim()).filter(Boolean).join(" \u2014 "));
60
+ // Emphasis runs, ONLY where they actually delimit: the opening run must be
61
+ // followed by non-whitespace and the closing run preceded by it — the same
62
+ // flanking rule CommonMark uses to decide this is emphasis at all. A blanket
63
+ // /[*~]+/ deleted the literal asterisk in `*.jamdesk.app`, changing what the
64
+ // sentence MEANS on all four meta surfaces while the rendered page kept it.
65
+ // Same class of bug as the underscore rule below, and it bites harder: docs
66
+ // about wildcard domains, globs and multiplication are exactly the docs that
67
+ // carry a lone asterisk.
68
+ // Emphasis NESTS (`**bold with *italic* inside**`) and the content class
69
+ // cannot span the inner delimiter, so one pass only ever removes the
70
+ // innermost pair. Run to a fixed point; the bound is a guard, not a limit —
71
+ // real prose nests two deep.
72
+ for (let pass = 0; pass < 4; pass++) {
73
+ const before = s;
74
+ s = s.replace(/\*+(?=\S)([^*]*[^\s*])\*+/g, "$1");
75
+ s = s.replace(/~+(?=\S)([^~]*[^\s~])~+/g, "$1");
76
+ if (s === before) break;
77
+ }
49
78
  // Underscores ONLY where they delimit emphasis. A blanket /[_]+/ turned
50
79
  // `user_id` into `userid` — and API descriptions, the main source here, are
51
80
  // full of snake_case identifiers whose whole value is being copy-pasteable.
@@ -146,12 +146,19 @@ function describeScheme(name, scopes, schemes) {
146
146
  * one suffices); each entry is a set of schemes that all apply together. An
147
147
  * operation's own `security` overrides the document default, and an explicit
148
148
  * empty array means this endpoint takes no auth — which is worth stating,
149
- * not worth omitting. Returns [] when neither level declares anything. */
149
+ * not worth omitting.
150
+ *
151
+ * ABSENT at both levels means the same thing as an explicit `[]`: OpenAPI
152
+ * defines an operation with no `security` and no document default as carrying
153
+ * no security requirement. This used to return [] instead, so six snag
154
+ * operations shipped `.md` exports and llms-full entries with no
155
+ * `## Authentication` section at all while their `security: []` siblings said
156
+ * "No authentication required." — the same fact, told two different ways, and
157
+ * an agent reading the silent one learns nothing about how to call it. */
150
158
  function securityLines(operationSecurity, specSecurity, schemes) {
151
159
  const security = Array.isArray(operationSecurity)
152
160
  ? operationSecurity
153
- : (Array.isArray(specSecurity) ? specSecurity : null);
154
- if (security === null) return [];
161
+ : (Array.isArray(specSecurity) ? specSecurity : []);
155
162
 
156
163
  const out = ['## Authentication', ''];
157
164
  if (security.length === 0) {
@@ -185,6 +192,15 @@ function resolveServerTemplate(server) {
185
192
  return String(server.url).replace(/\{(\w+)\}/g, (_, key) => (server.variables && server.variables[key] && server.variables[key].default) || `{${key}}`);
186
193
  }
187
194
 
195
+ /** `schemaLines` bounded to `maxLines`, with the notice INSIDE the block.
196
+ * Mirror of the TS twin — see lib/openapi/endpoint-to-markdown.ts for why the
197
+ * cap is per schema and not per document. */
198
+ function cappedSchemaLines(schema, opts, maxLines) {
199
+ const lines = schemaLines(schema, opts);
200
+ if (lines.length <= maxLines) return lines;
201
+ return [...lines.slice(0, maxLines), '', `… schema truncated at ${maxLines} lines.`];
202
+ }
203
+
188
204
  /** Flatten a dereferenced OpenAPI operation into markdown: method/path,
189
205
  * parameters grouped by location, request body and one `##` section per
190
206
  * response status code. Every heading is a real `##` line — never bold —
@@ -238,7 +254,7 @@ function endpointToMarkdown(operation, options = {}) {
238
254
  for (const [mime, media] of Object.entries(operation.requestBody.content)) {
239
255
  out.push(`- Content type: \`${mime}\``);
240
256
  if (media && media.schema) {
241
- out.push(...schemaLines(media.schema, { depth: 1, maxDepth, seen: new Set(), rootLabel: 'request body' }));
257
+ out.push(...cappedSchemaLines(media.schema, { depth: 1, maxDepth, seen: new Set(), rootLabel: 'request body' }, maxLines));
242
258
  }
243
259
  }
244
260
  out.push('');
@@ -254,7 +270,7 @@ function endpointToMarkdown(operation, options = {}) {
254
270
  const content = response.content || {};
255
271
  for (const media of Object.values(content)) {
256
272
  if (media && media.schema) {
257
- out.push(...schemaLines(media.schema, { depth: 1, maxDepth, seen: new Set(), rootLabel: 'response' }));
273
+ out.push(...cappedSchemaLines(media.schema, { depth: 1, maxDepth, seen: new Set(), rootLabel: 'response' }, maxLines));
258
274
  }
259
275
  }
260
276
  // Blank line per status, not once after the loop: without it the next
@@ -263,10 +279,6 @@ function endpointToMarkdown(operation, options = {}) {
263
279
  }
264
280
  }
265
281
 
266
- if (out.length > maxLines) {
267
- out.length = maxLines;
268
- out.push('', `… schema truncated at ${maxLines} lines.`);
269
- }
270
282
  return out.join('\n').replace(/\n{3,}/g, '\n\n').trim();
271
283
  }
272
284
 
@@ -79,7 +79,16 @@ const LINK_PATTERNS = [
79
79
  /**
80
80
  * Patterns to skip (external links, anchors, etc.)
81
81
  */
82
- function shouldSkipLink(href) {
82
+ /** Facts that decide whether a build-time route could exist for THIS project.
83
+ * Both were previously assumed true for everyone, so `broken-links` stayed
84
+ * silent on two links that really do 404: `/docs/feed.xml` on a project that
85
+ * does not host at /docs, and `/feed.xml` on a project where no page sets
86
+ * `rss: true`. Defaults keep the permissive behaviour for callers that do not
87
+ * pass facts (the exported helper is used directly in tests). */
88
+ const PERMISSIVE_FACTS = { hostAtDocs: true, hasRssPage: true };
89
+
90
+ function shouldSkipLink(href, facts) {
91
+ const { hostAtDocs, hasRssPage } = facts || PERMISSIVE_FACTS;
83
92
  if (!href) return true;
84
93
 
85
94
  // Skip external URLs
@@ -105,8 +114,14 @@ function shouldSkipLink(href) {
105
114
  // A project hosted at /docs writes these as `/docs/feed.xml`; resolveLink has
106
115
  // its own hostAtDocs branch for exactly this prefix. Strip one leading /docs
107
116
  // segment so the same five routes are recognised in both spellings.
108
- const candidate = withoutFragment.replace(/^\/docs(?=\/)/, '');
109
- if (GENERATED_ROUTES.has(candidate)) return true;
117
+ const candidate = hostAtDocs
118
+ ? withoutFragment.replace(/^\/docs(?=\/)/, '')
119
+ : withoutFragment;
120
+ if (GENERATED_ROUTES.has(candidate)) {
121
+ // feed.xml is the one route that is conditional on page content rather
122
+ // than on the project existing: no `rss: true` page, no feed.
123
+ return candidate === '/feed.xml' ? hasRssPage : true;
124
+ }
110
125
 
111
126
  // Skip image/asset paths (handles dimension syntax like =500x, =x200, =300x200)
112
127
  // Strips any trailing dimension suffix before checking extension
@@ -488,7 +503,7 @@ function getTargetHeadingSlugs(targetPath, contentDir, headingMap) {
488
503
  /**
489
504
  * Find all links in an MDX file and validate them
490
505
  */
491
- function validateFile(filePath, contentDir, results, headingMap) {
506
+ function validateFile(filePath, contentDir, results, headingMap, facts) {
492
507
  const content = fs.readFileSync(filePath, 'utf8');
493
508
  const relativePath = path.relative(contentDir, filePath);
494
509
  const currentPagePath = relativePath.replace(/\.mdx$/, '');
@@ -527,7 +542,7 @@ function validateFile(filePath, contentDir, results, headingMap) {
527
542
  // Has path component — validate path first
528
543
  if (!linkPath) continue;
529
544
 
530
- if (shouldSkipLink(linkPath)) continue;
545
+ if (shouldSkipLink(linkPath, facts)) continue;
531
546
 
532
547
  const targetPath = resolveLink(linkPath, filePath, contentDir);
533
548
  if (!targetPath) continue;
@@ -635,6 +650,38 @@ function validateNavigation(contentDir, navigationPages, results) {
635
650
  * Only validates pages that are part of docs.json navigation.
636
651
  * Files that exist but aren't in the navigation are ignored.
637
652
  */
653
+ /** See PERMISSIVE_FACTS. Reads docs.json for the hosting prefix and scans page
654
+ * frontmatter for a single `rss: true`. The scan is a raw regex rather than a
655
+ * YAML parse on purpose: this runs over every navigation page and only needs
656
+ * to answer "does any page opt into a feed". */
657
+ function readSiteFacts(contentDir, navigationPages) {
658
+ let hostAtDocs = false;
659
+ try {
660
+ const cfg = JSON.parse(fs.readFileSync(path.join(contentDir, 'docs.json'), 'utf8'));
661
+ hostAtDocs = cfg.hostAtDocs === true;
662
+ } catch (e) {
663
+ // Invalid/missing docs.json is reported elsewhere; assume the root layout.
664
+ }
665
+
666
+ let hasRssPage = false;
667
+ for (const pagePath of navigationPages) {
668
+ for (const candidate of [
669
+ path.join(contentDir, pagePath + '.mdx'),
670
+ path.join(contentDir, pagePath, 'index.mdx'),
671
+ ]) {
672
+ if (!fs.existsSync(candidate)) continue;
673
+ try {
674
+ if (/^rss:[ \t]*true[ \t]*$/m.test(fs.readFileSync(candidate, 'utf8'))) {
675
+ hasRssPage = true;
676
+ }
677
+ } catch (e) { /* unreadable page is reported elsewhere */ }
678
+ break;
679
+ }
680
+ if (hasRssPage) break;
681
+ }
682
+ return { hostAtDocs, hasRssPage };
683
+ }
684
+
638
685
  function validateProject(options) {
639
686
  options = options || {};
640
687
  const contentDir = getProjectDir();
@@ -649,6 +696,7 @@ function validateProject(options) {
649
696
 
650
697
  const navigationPages = getNavigationPages(contentDir);
651
698
  validateNavigation(contentDir, navigationPages, results);
699
+ const facts = readSiteFacts(contentDir, navigationPages);
652
700
 
653
701
  // Build heading map: use provided one or build from files
654
702
  const headingMap = options.headingMap || buildHeadingMapFromFiles(contentDir, navigationPages);
@@ -657,9 +705,9 @@ function validateProject(options) {
657
705
  const mdxPath = path.join(contentDir, pagePath + '.mdx');
658
706
  const indexPath = path.join(contentDir, pagePath, 'index.mdx');
659
707
  if (fs.existsSync(mdxPath)) {
660
- validateFile(mdxPath, contentDir, results, headingMap);
708
+ validateFile(mdxPath, contentDir, results, headingMap, facts);
661
709
  } else if (fs.existsSync(indexPath)) {
662
- validateFile(indexPath, contentDir, results, headingMap);
710
+ validateFile(indexPath, contentDir, results, headingMap, facts);
663
711
  }
664
712
  }
665
713
 
@@ -698,4 +746,4 @@ if (require.main === module) {
698
746
  // Export for programmatic use
699
747
  // generateSlug + createSlugger are exported for the drift test
700
748
  // (validate-links-slug.test.ts), which asserts they match the live github-slugger.
701
- module.exports = { validateProject, generateSlug, createSlugger, closestSlug, levenshtein, shouldSkipLink };
749
+ module.exports = { validateProject, generateSlug, createSlugger, closestSlug, levenshtein, shouldSkipLink, readSiteFacts };
@@ -575,19 +575,22 @@ body[data-theme="halo"] {
575
575
  height, and its <aside> is `lg:self-start` (height = content), so without a
576
576
  cap the nav grows unbounded and never scrolls once it's taller than the
577
577
  viewport. jam and nebula both carry this exact rule; halo's full-bleed rewrite
578
- dropped it. Header is 57px tall; a top tab strip adds another 57px (114px). */
578
+ dropped it. */
579
579
  @media (min-width: 1024px) {
580
580
  body[data-theme="halo"] .sidebar-scroll {
581
581
  position: sticky !important;
582
- flex: 0 0 auto !important;
583
- }
584
- body[data-theme="halo"] .sidebar-scroll[data-has-tabs="true"] {
585
- top: 114px !important;
586
- max-height: calc(100vh - 114px - var(--jd-banner-height, 0px)) !important;
587
- }
588
- body[data-theme="halo"] .sidebar-scroll[data-has-tabs="false"] {
589
- top: 57px !important;
590
- max-height: calc(100vh - 57px - var(--jd-banner-height, 0px)) !important;
582
+ top: 0 !important;
583
+ /* Fill the layout row, which LayoutWrapper has already sized to the space
584
+ left over after the banner, the header and the tabs bar. The rule this
585
+ replaces measured those back off 100vh with the header hardcoded at 57px
586
+ it renders 64px so the scroller overran its row by exactly 7px on
587
+ every desktop viewport and the row's `lg:overflow-hidden` clipped the
588
+ last nav item. Pairs with `lg:max-h-full` on the <aside>, which caps the
589
+ (content-sized, `self-start`) sidebar at the row height without changing
590
+ how a short nav renders. */
591
+ flex: 1 1 auto !important;
592
+ min-height: 0 !important;
593
+ max-height: none !important;
591
594
  }
592
595
  }
593
596
 
@@ -721,21 +721,24 @@ body[data-theme="jam"] .sidebar-scroll .sidebar-nav-groups > div:first-child {
721
721
  }
722
722
  }
723
723
 
724
- /* Sidebar scroll container positioning for header-logo layout */
725
- /* The sidebar-scroll div needs constrained height to scroll independently */
726
- /* When tabs exist at top, sidebar needs more top offset (header 57px + tabs 57px = 114px) */
724
+ /* Sidebar scroll container height cap for the header-logo layout — REQUIRED
725
+ for a tall nav to scroll. The shared markup gives .sidebar-scroll
726
+ `overflow-y-auto` but no bounded height, so the cap has to come from here. */
727
727
  @media (min-width: 1024px) {
728
728
  body[data-theme="jam"] .sidebar-scroll {
729
729
  position: sticky !important;
730
- flex: 0 0 auto !important;
731
- }
732
- body[data-theme="jam"] .sidebar-scroll[data-has-tabs="true"] {
733
- top: 114px !important;
734
- max-height: calc(100vh - 114px - var(--jd-banner-height, 0px)) !important;
735
- }
736
- body[data-theme="jam"] .sidebar-scroll[data-has-tabs="false"] {
737
- top: 57px !important;
738
- max-height: calc(100vh - 57px - var(--jd-banner-height, 0px)) !important;
730
+ top: 0 !important;
731
+ /* Fill the layout row, which LayoutWrapper has already sized to the space
732
+ left over after the banner, the header and the tabs bar. The rule this
733
+ replaces measured those back off 100vh with the header hardcoded at 57px
734
+ it renders 64px so the scroller overran its row by exactly 7px on
735
+ every desktop viewport and the row's `lg:overflow-hidden` clipped the
736
+ last nav item. Pairs with `lg:max-h-full` on the <aside>, which caps the
737
+ (content-sized, `self-start`) sidebar at the row height without changing
738
+ how a short nav renders. */
739
+ flex: 1 1 auto !important;
740
+ min-height: 0 !important;
741
+ max-height: none !important;
739
742
  }
740
743
  }
741
744
 
@@ -214,21 +214,24 @@ body[data-theme="nebula"] .content-scroll::-webkit-scrollbar {
214
214
  display: none;
215
215
  }
216
216
 
217
- /* Sidebar scroll container positioning for header-logo layout */
218
- /* The sidebar-scroll div needs constrained height to scroll independently */
219
- /* When tabs exist at top, sidebar needs more top offset (header 57px + tabs 57px = 114px) */
217
+ /* Sidebar scroll container height cap for the header-logo layout — REQUIRED
218
+ for a tall nav to scroll. The shared markup gives .sidebar-scroll
219
+ `overflow-y-auto` but no bounded height, so the cap has to come from here. */
220
220
  @media (min-width: 1024px) {
221
221
  body[data-theme="nebula"] .sidebar-scroll {
222
222
  position: sticky !important;
223
- flex: 0 0 auto !important;
224
- }
225
- body[data-theme="nebula"] .sidebar-scroll[data-has-tabs="true"] {
226
- top: 114px !important;
227
- max-height: calc(100vh - 114px - var(--jd-banner-height, 0px)) !important;
228
- }
229
- body[data-theme="nebula"] .sidebar-scroll[data-has-tabs="false"] {
230
- top: 57px !important;
231
- max-height: calc(100vh - 57px - var(--jd-banner-height, 0px)) !important;
223
+ top: 0 !important;
224
+ /* Fill the layout row, which LayoutWrapper has already sized to the space
225
+ left over after the banner, the header and the tabs bar. The rule this
226
+ replaces measured those back off 100vh with the header hardcoded at 57px
227
+ it renders 64px so the scroller overran its row by exactly 7px on
228
+ every desktop viewport and the row's `lg:overflow-hidden` clipped the
229
+ last nav item. Pairs with `lg:max-h-full` on the <aside>, which caps the
230
+ (content-sized, `self-start`) sidebar at the row height without changing
231
+ how a short nav renders. */
232
+ flex: 1 1 auto !important;
233
+ min-height: 0 !important;
234
+ max-height: none !important;
232
235
  }
233
236
  }
234
237