jamdesk 1.1.155 → 1.1.157
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/__tests__/unit/ai-fix-client.test.js +2 -1
- package/dist/__tests__/unit/ai-fix-client.test.js.map +1 -1
- package/dist/__tests__/unit/fix-path-containment.test.d.ts +2 -0
- package/dist/__tests__/unit/fix-path-containment.test.d.ts.map +1 -0
- package/dist/__tests__/unit/fix-path-containment.test.js +139 -0
- package/dist/__tests__/unit/fix-path-containment.test.js.map +1 -0
- package/dist/__tests__/unit/fix.test.js +5 -2
- package/dist/__tests__/unit/fix.test.js.map +1 -1
- package/dist/__tests__/unit/mdx-validator-path-containment.test.d.ts +2 -0
- package/dist/__tests__/unit/mdx-validator-path-containment.test.d.ts.map +1 -0
- package/dist/__tests__/unit/mdx-validator-path-containment.test.js +35 -0
- package/dist/__tests__/unit/mdx-validator-path-containment.test.js.map +1 -0
- package/dist/__tests__/unit/path-realpath.test.d.ts +2 -0
- package/dist/__tests__/unit/path-realpath.test.d.ts.map +1 -0
- package/dist/__tests__/unit/path-realpath.test.js +61 -0
- package/dist/__tests__/unit/path-realpath.test.js.map +1 -0
- package/dist/__tests__/unit/spellcheck-path-containment.test.d.ts +2 -0
- package/dist/__tests__/unit/spellcheck-path-containment.test.d.ts.map +1 -0
- package/dist/__tests__/unit/spellcheck-path-containment.test.js +56 -0
- package/dist/__tests__/unit/spellcheck-path-containment.test.js.map +1 -0
- package/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +3 -4
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/fix.d.ts +3 -1
- package/dist/commands/fix.d.ts.map +1 -1
- package/dist/commands/fix.js +67 -12
- package/dist/commands/fix.js.map +1 -1
- package/dist/commands/spellcheck.d.ts +1 -0
- package/dist/commands/spellcheck.d.ts.map +1 -1
- package/dist/commands/spellcheck.js +23 -9
- package/dist/commands/spellcheck.js.map +1 -1
- package/dist/commands/validate.d.ts.map +1 -1
- package/dist/commands/validate.js +14 -11
- package/dist/commands/validate.js.map +1 -1
- package/dist/lib/ai-fix-client.js +1 -1
- package/dist/lib/ai-fix-client.js.map +1 -1
- package/dist/lib/mdx-validator.d.ts +1 -0
- package/dist/lib/mdx-validator.d.ts.map +1 -1
- package/dist/lib/mdx-validator.js +24 -9
- package/dist/lib/mdx-validator.js.map +1 -1
- package/dist/lib/path-realpath.d.ts +3 -0
- package/dist/lib/path-realpath.d.ts.map +1 -0
- package/dist/lib/path-realpath.js +90 -0
- package/dist/lib/path-realpath.js.map +1 -0
- package/dist/lib/spellcheck-fix.d.ts +4 -0
- package/dist/lib/spellcheck-fix.d.ts.map +1 -1
- package/dist/lib/spellcheck-fix.js +33 -7
- package/dist/lib/spellcheck-fix.js.map +1 -1
- package/package.json +1 -1
- package/vendored/app/api/og/route.tsx +1 -0
- package/vendored/app/not-found.tsx +6 -0
- package/vendored/lib/mcp-search.ts +12 -11
- package/vendored/lib/openapi/operation-description.ts +110 -0
- package/vendored/lib/r2-content.ts +6 -0
- package/vendored/lib/render-doc-page.tsx +80 -1
- package/vendored/lib/static-artifacts.ts +8 -2
- package/vendored/lib/validate-page-frontmatter.ts +39 -3
- package/vendored/themes/jam/variables.css +11 -0
- package/vendored/themes/nebula/variables.css +10 -0
- package/vendored/workspace-package-lock.json +198 -282
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// builder/build-service/lib/openapi/operation-description.ts
|
|
2
|
+
//
|
|
3
|
+
// Pure, dependency-free helpers for deriving a page meta description from an
|
|
4
|
+
// OpenAPI operation. Used by the build-service (llms.txt, <meta>, warning
|
|
5
|
+
// re-messaging) AND — vendored verbatim — by the dashboard Fix-with-AI
|
|
6
|
+
// pipeline, so both surfaces agree on the exact text and the length gate.
|
|
7
|
+
//
|
|
8
|
+
// IMPORT-FREE of `@/` aliases: vendor-openapi.mjs (exit 1 on `@/`) copies this
|
|
9
|
+
// file into dashboard/functions/src/editor/openapi/. Keep it self-contained.
|
|
10
|
+
|
|
11
|
+
/** Frontmatter descriptions shorter than this flag `page_short_description`.
|
|
12
|
+
* MUST equal MIN_DESCRIPTION_CHARS in validate-page-frontmatter.ts (asserted
|
|
13
|
+
* by a test). A sourced description is WRITTEN into frontmatter (Slice 3) only
|
|
14
|
+
* when its cleaned length is >= this, so the write never trades a "missing"
|
|
15
|
+
* warning for a "too short" one. */
|
|
16
|
+
export const MIN_FRONTMATTER_DESCRIPTION_CHARS = 110;
|
|
17
|
+
|
|
18
|
+
/** Search engines truncate meta descriptions around 155-160 chars. */
|
|
19
|
+
const MAX_META_DESCRIPTION_CHARS = 160;
|
|
20
|
+
|
|
21
|
+
/** The two operation fields we read. Loose types: specs are untrusted input. */
|
|
22
|
+
export interface OperationSummary {
|
|
23
|
+
summary?: unknown;
|
|
24
|
+
description?: unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Minimum chars before a sentence/word boundary is worth cutting to (avoids
|
|
28
|
+
* truncating to a tiny fragment like "OK."). Matches the word-boundary floor. */
|
|
29
|
+
const MIN_TRUNCATION_CHARS = 40;
|
|
30
|
+
|
|
31
|
+
/** Reduce markdown to plain prose fit for a <meta>/llms description: drop
|
|
32
|
+
* fenced+inline code, images, links (keep the text), headings/list/blockquote
|
|
33
|
+
* markers and emphasis; collapse whitespace. If over the limit, prefer ending
|
|
34
|
+
* on a COMPLETE SENTENCE within the limit (no ellipsis — it's a whole thought);
|
|
35
|
+
* else cut on a word boundary with an ellipsis. "" for empty input.
|
|
36
|
+
* All scanning is O(n) char-by-char — deliberately no truncation regex, to
|
|
37
|
+
* avoid the catastrophic-backtracking class this repo has been bitten by. */
|
|
38
|
+
export function cleanMetaDescription(text: string): string {
|
|
39
|
+
let s = text;
|
|
40
|
+
s = s.replace(/```[\s\S]*?```/g, " "); // fenced code blocks
|
|
41
|
+
s = s.replace(/`([^`]+)`/g, "$1"); // inline code
|
|
42
|
+
s = s.replace(/!\[[^\]]*\]\([^)]*\)/g, " "); // images
|
|
43
|
+
s = s.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1"); // links -> link text
|
|
44
|
+
s = s.replace(/^[ \t]*[#>*\-+]+[ \t]*/gm, ""); // heading/quote/list markers
|
|
45
|
+
s = s.replace(/[*_~]+/g, ""); // emphasis runs
|
|
46
|
+
s = s.replace(/\s+/g, " ").trim(); // collapse all whitespace
|
|
47
|
+
if (s.length <= MAX_META_DESCRIPTION_CHARS) return s;
|
|
48
|
+
const cut = s.slice(0, MAX_META_DESCRIPTION_CHARS);
|
|
49
|
+
// Last sentence terminator (.?!) that is followed by a space or end-of-cut.
|
|
50
|
+
let sentenceEnd = -1;
|
|
51
|
+
for (let i = 0; i < cut.length; i++) {
|
|
52
|
+
const c = cut[i];
|
|
53
|
+
if ((c === "." || c === "!" || c === "?") &&
|
|
54
|
+
(i + 1 >= cut.length || cut[i + 1] === " ")) {
|
|
55
|
+
sentenceEnd = i;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (sentenceEnd >= MIN_TRUNCATION_CHARS) {
|
|
59
|
+
return cut.slice(0, sentenceEnd + 1).trimEnd(); // complete sentence, no ellipsis
|
|
60
|
+
}
|
|
61
|
+
const lastSpace = cut.lastIndexOf(" ");
|
|
62
|
+
return (lastSpace > MIN_TRUNCATION_CHARS ? cut.slice(0, lastSpace) : cut)
|
|
63
|
+
.trimEnd() + "…";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Operation description (preferred) or summary (fallback), cleaned. null when
|
|
67
|
+
* neither yields non-empty text. No length gate — callers apply their own. */
|
|
68
|
+
export function deriveOperationDescription(
|
|
69
|
+
op: OperationSummary,
|
|
70
|
+
): string | null {
|
|
71
|
+
const desc =
|
|
72
|
+
typeof op.description === "string" ? cleanMetaDescription(op.description) : "";
|
|
73
|
+
if (desc) return desc;
|
|
74
|
+
const summary =
|
|
75
|
+
typeof op.summary === "string" ? cleanMetaDescription(op.summary) : "";
|
|
76
|
+
return summary || null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Look up an operation by method+path in a parsed (not necessarily
|
|
80
|
+
* dereferenced) OpenAPI document. Tolerant of unknown shapes. Method is
|
|
81
|
+
* case-insensitive. null if not present. */
|
|
82
|
+
export function findOperation(
|
|
83
|
+
spec: unknown,
|
|
84
|
+
method: string,
|
|
85
|
+
path: string,
|
|
86
|
+
): OperationSummary | null {
|
|
87
|
+
if (!spec || typeof spec !== "object") return null;
|
|
88
|
+
const paths = (spec as {paths?: unknown}).paths;
|
|
89
|
+
if (!paths || typeof paths !== "object") return null;
|
|
90
|
+
const pathItem = (paths as Record<string, unknown>)[path];
|
|
91
|
+
if (!pathItem || typeof pathItem !== "object") return null;
|
|
92
|
+
const op = (pathItem as Record<string, unknown>)[method.toLowerCase()];
|
|
93
|
+
if (!op || typeof op !== "object") return null;
|
|
94
|
+
return op as OperationSummary;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Normalize `config.api.openapi` (string | string[] | undefined) to the list
|
|
98
|
+
* of repo-relative spec paths a build/fix pipeline can read. Drops remote
|
|
99
|
+
* `http(s)://` URLs — those are fetched by the renderer, never present as
|
|
100
|
+
* local blobs. Lives here (shared, vendored) so the build-service and the
|
|
101
|
+
* dashboard apply an identical remote-skip rule. */
|
|
102
|
+
export function collectLocalSpecPaths(apiOpenapi: unknown): string[] {
|
|
103
|
+
const raw =
|
|
104
|
+
typeof apiOpenapi === "string"
|
|
105
|
+
? [apiOpenapi]
|
|
106
|
+
: Array.isArray(apiOpenapi)
|
|
107
|
+
? apiOpenapi.filter((s): s is string => typeof s === "string")
|
|
108
|
+
: [];
|
|
109
|
+
return raw.filter((p) => !/^https?:\/\//i.test(p));
|
|
110
|
+
}
|
|
@@ -77,3 +77,9 @@ export function withR2OpsContext<T>(fn: () => T): T { return fn(); }
|
|
|
77
77
|
export function enterR2OpsContextForTest<T>(fn: () => T): T { return fn(); }
|
|
78
78
|
export function emitR2OpsSummary(_phase: R2TimingPhase, _projectSlug?: string | null): void {}
|
|
79
79
|
export async function withTimeout<T>(promise: Promise<T>, _operation: string): Promise<T> { return promise; }
|
|
80
|
+
// Pure helper (no AWS SDK) — mirror the real impl so docs-config timeout parsing
|
|
81
|
+
// behaves identically in CLI dev as in prod.
|
|
82
|
+
export function resolveR2Timeout(raw: string | undefined): number {
|
|
83
|
+
const parsed = parseInt((raw ?? '').trim(), 10);
|
|
84
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 5000;
|
|
85
|
+
}
|
|
@@ -84,6 +84,7 @@ import {
|
|
|
84
84
|
type AuthMethod,
|
|
85
85
|
} from '@/lib/openapi';
|
|
86
86
|
import { classifyOpenApiLoadError } from '@/lib/openapi/classify-load-error';
|
|
87
|
+
import { findOperation, deriveOperationDescription, collectLocalSpecPaths } from '@/lib/openapi/operation-description';
|
|
87
88
|
import { extractLanguageFromPath, isValidLanguageCode } from '@/lib/language-utils';
|
|
88
89
|
import { findFirstNavPage } from '@/lib/find-first-nav-page';
|
|
89
90
|
import { candidateSpecPaths } from '@/lib/openapi/lang-spec-path';
|
|
@@ -210,6 +211,47 @@ function resolveSlug(normalizedSlug: string[], config: DocsConfig): string[] {
|
|
|
210
211
|
return normalizedSlug;
|
|
211
212
|
}
|
|
212
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Resolve a page `<meta>`/og description from its OpenAPI operation when the
|
|
216
|
+
* page has no frontmatter `description`. Pure: the spec loader is injected so
|
|
217
|
+
* this is unit-testable independent of the render path (and so `buildDocMetadata`
|
|
218
|
+
* can hand it the SAME cached loader the renderer uses — no extra spec fetch).
|
|
219
|
+
*
|
|
220
|
+
* `loadSpec` MUST be a cached loader (see `buildDocMetadata` wiring): docs
|
|
221
|
+
* routes are `force-dynamic`, so this runs on every page view; an uncached
|
|
222
|
+
* fetch+parse here would add an R2 round-trip per render. Remote (`http(s)://`)
|
|
223
|
+
* spec entries are dropped — they are fetched by the renderer, never resolvable
|
|
224
|
+
* as a meta-time read. Returns null on any unresolvable/unparseable input so the
|
|
225
|
+
* caller degrades to `generateAutoDescription`.
|
|
226
|
+
*/
|
|
227
|
+
export async function resolveOpenApiMetaDescription(
|
|
228
|
+
openapiRef: string,
|
|
229
|
+
specPaths: string[],
|
|
230
|
+
loadSpec: (specPath: string) => Promise<unknown | null>,
|
|
231
|
+
): Promise<string | null> {
|
|
232
|
+
const local = specPaths.filter((p) => !/^https?:\/\//i.test(p));
|
|
233
|
+
if (local.length === 0) return null;
|
|
234
|
+
let parsed;
|
|
235
|
+
try {
|
|
236
|
+
parsed = parseOpenApiFrontmatter(openapiRef, local);
|
|
237
|
+
} catch {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
// Full-format ref carries its own spec path; short-format must try each
|
|
241
|
+
// configured spec until the operation resolves.
|
|
242
|
+
const candidates = parsed.isShortFormat ? local : [parsed.specPath];
|
|
243
|
+
for (const sp of candidates) {
|
|
244
|
+
if (!sp) continue;
|
|
245
|
+
const spec = await loadSpec(sp);
|
|
246
|
+
if (!spec) continue;
|
|
247
|
+
const op = findOperation(spec, parsed.method, parsed.path);
|
|
248
|
+
if (!op) continue;
|
|
249
|
+
const d = deriveOperationDescription(op);
|
|
250
|
+
if (d) return d;
|
|
251
|
+
}
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
|
|
213
255
|
export async function buildDocMetadata(input: RenderInput): Promise<Metadata> {
|
|
214
256
|
const { slug: slugInput, projectSlug, hostAtDocs, requestHeaders } = input;
|
|
215
257
|
|
|
@@ -252,7 +294,44 @@ export async function buildDocMetadata(input: RenderInput): Promise<Metadata> {
|
|
|
252
294
|
const data = parsed.data as FrontmatterData;
|
|
253
295
|
|
|
254
296
|
if (!data.description) {
|
|
255
|
-
data.
|
|
297
|
+
if (typeof data.openapi === 'string' && data.openapi && config.api?.openapi) {
|
|
298
|
+
// Source the description from the OpenAPI operation before falling back to
|
|
299
|
+
// first-paragraph extraction (which is empty for prose-less API pages).
|
|
300
|
+
// The spec loader REUSES the render path's cached loaders — the ISR module
|
|
301
|
+
// cache (`r2:${slug}:${specPath}`, 10-min TTL) that `renderDocPage`
|
|
302
|
+
// populates when it renders the page's <ApiEndpoint>, or the static
|
|
303
|
+
// `getCachedSpec`. So the spec is the same warm parse the render already
|
|
304
|
+
// did: the marginal cost of this lookup is a cache hit, never an uncached
|
|
305
|
+
// fetch+parse. That matters because docs routes are force-dynamic, so this
|
|
306
|
+
// metadata runs on EVERY page view (every human + crawler). Mirrors the
|
|
307
|
+
// `useIsr`/`projectDir` derivation in `renderDocPage`'s OpenAPI branch.
|
|
308
|
+
const specPaths = collectLocalSpecPaths(config.api.openapi);
|
|
309
|
+
const useIsr = isIsrMode() && !!projectSlug;
|
|
310
|
+
const projectDir = useIsr ? null : getContentDir();
|
|
311
|
+
const loadSpecForMeta = async (sp: string): Promise<unknown | null> => {
|
|
312
|
+
try {
|
|
313
|
+
if (useIsr && projectSlug) {
|
|
314
|
+
const { resolveOpenApiSpec } = await import('@/lib/openapi-isr');
|
|
315
|
+
return await resolveOpenApiSpec(projectSlug, sp);
|
|
316
|
+
}
|
|
317
|
+
if (projectDir) {
|
|
318
|
+
const { api } = await getCachedSpec(sp, projectDir);
|
|
319
|
+
return api;
|
|
320
|
+
}
|
|
321
|
+
return null;
|
|
322
|
+
} catch {
|
|
323
|
+
// Any load/parse error degrades to generateAutoDescription below.
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
const sourced = await resolveOpenApiMetaDescription(
|
|
328
|
+
data.openapi, specPaths, loadSpecForMeta,
|
|
329
|
+
);
|
|
330
|
+
if (sourced) data.description = sourced;
|
|
331
|
+
}
|
|
332
|
+
if (!data.description) {
|
|
333
|
+
data.description = generateAutoDescription(parsed.content);
|
|
334
|
+
}
|
|
256
335
|
}
|
|
257
336
|
|
|
258
337
|
const baseUrl = resolveBaseUrl(requestHeaders, projectSlug, hostAtDocs);
|
|
@@ -436,14 +436,20 @@ export interface RawPageInfo {
|
|
|
436
436
|
* @param pages - Array of raw page info
|
|
437
437
|
* @returns Array of page metadata
|
|
438
438
|
*/
|
|
439
|
-
export function extractPageMetadata(
|
|
439
|
+
export function extractPageMetadata(
|
|
440
|
+
pages: RawPageInfo[],
|
|
441
|
+
sourced?: Map<string, {description: string}>,
|
|
442
|
+
): PageMetadata[] {
|
|
440
443
|
return pages.map(page => {
|
|
441
444
|
const pathWithoutExt = page.path.replace(/\.mdx?$/, '');
|
|
442
445
|
|
|
443
446
|
return {
|
|
444
447
|
path: pathWithoutExt,
|
|
445
448
|
title: page.frontmatter.title || pathWithoutExt,
|
|
446
|
-
description
|
|
449
|
+
// Frontmatter wins; else fall back to a description sourced from the
|
|
450
|
+
// page's OpenAPI operation (keyed by the extension-ful page.path).
|
|
451
|
+
description:
|
|
452
|
+
page.frontmatter.description ?? sourced?.get(page.path)?.description,
|
|
447
453
|
noindex: page.frontmatter.noindex || page.frontmatter.seo?.noindex,
|
|
448
454
|
hidden: page.frontmatter.hidden,
|
|
449
455
|
lastModified: page.frontmatter.lastModified,
|
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
import type { BuildWarning } from '../shared/status-reporter.js'; // vendored copy — NOT '../../shared'
|
|
2
|
+
import { MIN_FRONTMATTER_DESCRIPTION_CHARS } from './openapi/operation-description.js';
|
|
2
3
|
|
|
3
4
|
const MIN_DESCRIPTION_CHARS = 110; // matches the Ahrefs "too short" floor used elsewhere
|
|
5
|
+
// Task 1 invariant: MIN_DESCRIPTION_CHARS must equal MIN_FRONTMATTER_DESCRIPTION_CHARS.
|
|
6
|
+
// Asserted by test. Do NOT change either value without updating both.
|
|
4
7
|
|
|
5
8
|
interface PageInfo {
|
|
6
9
|
path: string;
|
|
7
|
-
frontmatter: { title?: unknown; description?: unknown; hidden?: unknown };
|
|
10
|
+
frontmatter: { title?: unknown; description?: unknown; hidden?: unknown; openapi?: unknown };
|
|
8
11
|
content: string;
|
|
9
12
|
}
|
|
10
13
|
|
|
11
|
-
export function validatePageFrontmatter(
|
|
14
|
+
export function validatePageFrontmatter(
|
|
15
|
+
pages: PageInfo[],
|
|
16
|
+
sourced?: Map<string, { description: string; length: number }>,
|
|
17
|
+
): BuildWarning[] {
|
|
12
18
|
const warnings: BuildWarning[] = [];
|
|
13
19
|
for (const page of pages) {
|
|
14
20
|
const fm = page.frontmatter ?? {};
|
|
@@ -21,7 +27,37 @@ export function validatePageFrontmatter(pages: PageInfo[]): BuildWarning[] {
|
|
|
21
27
|
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
28
|
}
|
|
23
29
|
if (!description) {
|
|
24
|
-
|
|
30
|
+
const src = sourced?.get(page.path);
|
|
31
|
+
if (src) {
|
|
32
|
+
if (src.length >= MIN_FRONTMATTER_DESCRIPTION_CHARS) {
|
|
33
|
+
// Cat 1: OpenAPI-sourced description is long enough for Fix-with-AI to write
|
|
34
|
+
// into frontmatter — keep the warning but re-message it toward that action.
|
|
35
|
+
warnings.push({
|
|
36
|
+
type: 'page_missing_description',
|
|
37
|
+
file: page.path,
|
|
38
|
+
message: "No frontmatter description — your page's description is auto-sourced " +
|
|
39
|
+
"from your OpenAPI spec. Use Fix with AI to write it into the page for portability.",
|
|
40
|
+
});
|
|
41
|
+
} else {
|
|
42
|
+
// Cat 2: sourced text IS live in <meta>/llms, but too short to commit via
|
|
43
|
+
// Fix-with-AI. Do NOT suppress — the author still needs a longer description.
|
|
44
|
+
// A sourced-short description must warn no less than an authored-short one
|
|
45
|
+
// (else `summary: "TODO"` silently becomes the live meta + OG card with zero
|
|
46
|
+
// signal to fix it).
|
|
47
|
+
warnings.push({
|
|
48
|
+
type: 'page_missing_description',
|
|
49
|
+
file: page.path,
|
|
50
|
+
message:
|
|
51
|
+
`Description auto-sourced from your OpenAPI spec is only ` +
|
|
52
|
+
`${src.length} chars — add a longer frontmatter description ` +
|
|
53
|
+
`(aim for ${MIN_FRONTMATTER_DESCRIPTION_CHARS}–160; too short ` +
|
|
54
|
+
`for Fix with AI to write into the page).`,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
} else {
|
|
58
|
+
// Cat 3: genuinely missing, nothing to source from the spec.
|
|
59
|
+
warnings.push({ type: 'page_missing_description', file: page.path, message: 'Page has no frontmatter description; add one for SEO and llms.txt.' });
|
|
60
|
+
}
|
|
25
61
|
} else if (description.length < MIN_DESCRIPTION_CHARS) {
|
|
26
62
|
warnings.push({ type: 'page_short_description', file: page.path, message: `Description is ${description.length} chars; aim for ${MIN_DESCRIPTION_CHARS}–160.` });
|
|
27
63
|
}
|
|
@@ -710,6 +710,17 @@ body[data-theme="jam"] .sidebar-scroll .sidebar-nav-groups > div:first-child {
|
|
|
710
710
|
margin-top: 0 !important;
|
|
711
711
|
}
|
|
712
712
|
|
|
713
|
+
/* When the nav groups are the first thing in the sidebar (no left tabs / external
|
|
714
|
+
anchors), align the first group heading with the breadcrumb + "On this page" TOC
|
|
715
|
+
heading, both of which sit 40px down via the content/TOC `py-10` wrapper. The
|
|
716
|
+
base 16px above is the tabs→groups gap for the sidebar-tabs case, so only bump
|
|
717
|
+
the first-child, desktop-only path here. */
|
|
718
|
+
@media (min-width: 1024px) {
|
|
719
|
+
body[data-theme="jam"] .sidebar-scroll nav > .sidebar-nav-groups:first-child {
|
|
720
|
+
padding-top: 2.5rem !important;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
713
724
|
/* Sidebar scroll container positioning for header-logo layout */
|
|
714
725
|
/* The sidebar-scroll div needs constrained height to scroll independently */
|
|
715
726
|
/* When tabs exist at top, sidebar needs more top offset (header 57px + tabs 57px = 114px) */
|
|
@@ -294,3 +294,13 @@ body[data-theme="nebula"] .sidebar-scroll .sidebar-nav-groups > div.ml-6 {
|
|
|
294
294
|
padding-top: 16px;
|
|
295
295
|
}
|
|
296
296
|
|
|
297
|
+
/* When the nav groups are the first thing in the sidebar (no left tabs / external
|
|
298
|
+
anchors), align the first group heading with the breadcrumb + "On this page" TOC
|
|
299
|
+
heading, both 40px down via the content/TOC `py-10` wrapper. The 16px above is the
|
|
300
|
+
inter-group gap, so only the first group on desktop gets the larger offset. */
|
|
301
|
+
@media (min-width: 1024px) {
|
|
302
|
+
body[data-theme="nebula"] .sidebar-scroll nav > .sidebar-nav-groups:first-child > div.ml-6:first-child {
|
|
303
|
+
padding-top: 2.5rem;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|