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.
- package/dist/lib/inert-config-keys.d.ts.map +1 -1
- package/dist/lib/inert-config-keys.js +45 -8
- package/dist/lib/inert-config-keys.js.map +1 -1
- package/package.json +1 -1
- package/vendored/app/api/markdown-export/[project]/[...slug]/route.ts +7 -1
- package/vendored/app/not-found.tsx +21 -0
- package/vendored/components/mdx/ApiMarkdown.tsx +7 -1
- package/vendored/components/mdx/OpenApiEndpoint.tsx +46 -1
- package/vendored/components/navigation/Sidebar.tsx +7 -1
- package/vendored/lib/inert-config-keys.ts +48 -8
- package/vendored/lib/openapi/endpoint-to-markdown.ts +35 -9
- package/vendored/lib/openapi/operation-description.ts +31 -2
- package/vendored/scripts/endpoint-to-markdown.cjs +21 -9
- package/vendored/scripts/validate-links.cjs +56 -8
- package/vendored/themes/halo/variables.css +13 -10
- package/vendored/themes/jam/variables.css +15 -12
- package/vendored/themes/nebula/variables.css +15 -12
|
@@ -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;
|
|
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
|
-
|
|
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
|
-
|
|
51
|
+
nodes.forEach((n, i) => {
|
|
30
52
|
if (!isNode(n) || seen.has(n))
|
|
31
|
-
|
|
53
|
+
return;
|
|
32
54
|
seen.add(n);
|
|
55
|
+
const here = `${path}[${i}]`;
|
|
33
56
|
if (n.asyncapi)
|
|
34
|
-
out.push({ key: `${
|
|
35
|
-
//
|
|
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], `${
|
|
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;
|
|
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.
|
|
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
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
43
|
-
if (!isNode(n) || seen.has(n))
|
|
67
|
+
nodes.forEach((n, i) => {
|
|
68
|
+
if (!isNode(n) || seen.has(n)) return;
|
|
44
69
|
seen.add(n);
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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.
|
|
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 :
|
|
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(...
|
|
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(...
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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 :
|
|
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(...
|
|
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(...
|
|
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
|
-
|
|
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 =
|
|
109
|
-
|
|
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.
|
|
578
|
+
dropped it. */
|
|
579
579
|
@media (min-width: 1024px) {
|
|
580
580
|
body[data-theme="halo"] .sidebar-scroll {
|
|
581
581
|
position: sticky !important;
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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
|
|
725
|
-
|
|
726
|
-
|
|
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
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
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
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
|