@specglass/core 0.0.4 → 0.0.6
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/content/mdx-loader.js +5 -2
- package/dist/index.d.ts +5 -1
- package/dist/index.js +6 -1
- package/dist/integration.js +4 -0
- package/dist/navigation/builder.d.ts +13 -0
- package/dist/navigation/builder.js +29 -8
- package/dist/openapi/code-generator.js +5 -3
- package/dist/openapi/example-generator.js +8 -1
- package/dist/openapi/types.d.ts +4 -0
- package/dist/openapi/utils.d.ts +13 -1
- package/dist/openapi/utils.js +18 -0
- package/dist/validation/frontmatter-validator.d.ts +54 -0
- package/dist/validation/frontmatter-validator.js +197 -0
- package/dist/validation/index.d.ts +17 -0
- package/dist/validation/index.js +17 -0
- package/dist/validation/link-validator.d.ts +84 -0
- package/dist/validation/link-validator.js +490 -0
- package/dist/validation/spec-drift-validator.d.ts +88 -0
- package/dist/validation/spec-drift-validator.js +276 -0
- package/dist/validation/types.d.ts +45 -0
- package/dist/validation/types.js +15 -0
- package/package.json +2 -1
- package/src/pages/api-reference/[...slug].astro +27 -16
- package/src/pages/index.astro +27 -0
|
@@ -54,13 +54,16 @@ export function defineDocsCollection(options) {
|
|
|
54
54
|
export function getSlugFromFilePath(filePath) {
|
|
55
55
|
// Normalize path separators
|
|
56
56
|
let slug = filePath.replace(/\\/g, "/");
|
|
57
|
-
// Strip leading content/
|
|
57
|
+
// Strip leading content/ and/or docs/ prefixes.
|
|
58
|
+
// In monorepo context, entry.id is "content/docs/getting-started.mdx".
|
|
59
|
+
// When consumed via npm, the glob loader produces "docs/getting-started.mdx".
|
|
58
60
|
slug = slug.replace(/^content\//, "");
|
|
61
|
+
slug = slug.replace(/^docs\//, "");
|
|
59
62
|
// Strip file extension (.mdx or .md)
|
|
60
63
|
slug = slug.replace(/\.(mdx|md)$/, "");
|
|
61
64
|
// Handle index files — map to parent directory
|
|
62
65
|
slug = slug.replace(/\/index$/, "");
|
|
63
|
-
// Handle root index (just "index" after stripping
|
|
66
|
+
// Handle root index (just "index" after stripping prefixes)
|
|
64
67
|
if (slug === "index") {
|
|
65
68
|
return "";
|
|
66
69
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -19,10 +19,14 @@ export type { NavigationWatcherOptions } from "./navigation/watcher.js";
|
|
|
19
19
|
export { SpecglassError } from "./errors/specglass-error.js";
|
|
20
20
|
export { parseOpenApiSpec } from "./openapi/parser.js";
|
|
21
21
|
export { transformSpec } from "./openapi/transformer.js";
|
|
22
|
-
export { buildEndpointSlug, buildEndpointId } from "./openapi/utils.js";
|
|
22
|
+
export { buildEndpointSlug, buildErrorEndpointSlug, buildEndpointId } from "./openapi/utils.js";
|
|
23
23
|
export type { ApiEndpoint, ApiEndpointError, ApiExample, ApiMediaType, ApiParameter, ApiRequestBody, ApiResponse, ApiSchema, ApiSecurityRequirement, ParsedOpenApiSpec, } from "./openapi/types.js";
|
|
24
24
|
export { loadOpenApiContent, } from "./content/openapi-loader.js";
|
|
25
25
|
export type { OpenApiLoaderConfig, OpenApiContentEntry, OpenApiLoaderResult, } from "./content/openapi-loader.js";
|
|
26
26
|
export { generateExampleValue, extractMediaTypeExample, } from "./openapi/example-generator.js";
|
|
27
27
|
export { generateCurlExample, generatePythonExample, generateNodeExample, buildRequestUrl, } from "./openapi/code-generator.js";
|
|
28
28
|
export type { CodeExampleOptions } from "./openapi/code-generator.js";
|
|
29
|
+
export { runValidators, toSpecglassError, type ValidationResult, type ValidationSeverity, type ValidatorContext, type Validator, } from "./validation/index.js";
|
|
30
|
+
export { validateLinks } from "./validation/link-validator.js";
|
|
31
|
+
export { validateFrontmatter } from "./validation/frontmatter-validator.js";
|
|
32
|
+
export { validateSpecDrift } from "./validation/spec-drift-validator.js";
|
package/dist/index.js
CHANGED
|
@@ -21,8 +21,13 @@ export { SpecglassError } from "./errors/specglass-error.js";
|
|
|
21
21
|
// OpenAPI
|
|
22
22
|
export { parseOpenApiSpec } from "./openapi/parser.js";
|
|
23
23
|
export { transformSpec } from "./openapi/transformer.js";
|
|
24
|
-
export { buildEndpointSlug, buildEndpointId } from "./openapi/utils.js";
|
|
24
|
+
export { buildEndpointSlug, buildErrorEndpointSlug, buildEndpointId } from "./openapi/utils.js";
|
|
25
25
|
export { loadOpenApiContent, } from "./content/openapi-loader.js";
|
|
26
26
|
// OpenAPI example generation
|
|
27
27
|
export { generateExampleValue, extractMediaTypeExample, } from "./openapi/example-generator.js";
|
|
28
28
|
export { generateCurlExample, generatePythonExample, generateNodeExample, buildRequestUrl, } from "./openapi/code-generator.js";
|
|
29
|
+
// Validation
|
|
30
|
+
export { runValidators, toSpecglassError, } from "./validation/index.js";
|
|
31
|
+
export { validateLinks } from "./validation/link-validator.js";
|
|
32
|
+
export { validateFrontmatter } from "./validation/frontmatter-validator.js";
|
|
33
|
+
export { validateSpecDrift } from "./validation/spec-drift-validator.js";
|
package/dist/integration.js
CHANGED
|
@@ -194,6 +194,10 @@ export const specglassIntegration = defineIntegration({
|
|
|
194
194
|
rehypePlugins: [rehypeCodeBlocks],
|
|
195
195
|
},
|
|
196
196
|
});
|
|
197
|
+
params.injectRoute({
|
|
198
|
+
pattern: "/",
|
|
199
|
+
entrypoint: "@specglass/core/pages/index.astro",
|
|
200
|
+
});
|
|
197
201
|
params.injectRoute({
|
|
198
202
|
pattern: "/[...slug]",
|
|
199
203
|
entrypoint: "@specglass/core/pages/[...slug].astro",
|
|
@@ -7,6 +7,19 @@ import type { NavigationTree } from "../types/navigation.js";
|
|
|
7
7
|
* "api-reference" → "Api Reference"
|
|
8
8
|
*/
|
|
9
9
|
export declare function toTitleCase(kebab: string): string;
|
|
10
|
+
/**
|
|
11
|
+
* Normalize a directory or file name into a URL-safe slug segment.
|
|
12
|
+
*
|
|
13
|
+
* Matches Astro glob loader behavior:
|
|
14
|
+
* - lowercases the name
|
|
15
|
+
* - replaces whitespace runs with a single hyphen
|
|
16
|
+
*
|
|
17
|
+
* Examples:
|
|
18
|
+
* "Prompt Optimization" → "prompt-optimization"
|
|
19
|
+
* "Introduction" → "introduction"
|
|
20
|
+
* "getting-started" → "getting-started"
|
|
21
|
+
*/
|
|
22
|
+
export declare function slugify(name: string): string;
|
|
10
23
|
/**
|
|
11
24
|
* Build a navigation tree by recursively walking a content directory.
|
|
12
25
|
*
|
|
@@ -14,6 +14,21 @@ export function toTitleCase(kebab) {
|
|
|
14
14
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
15
15
|
.join(" ");
|
|
16
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* Normalize a directory or file name into a URL-safe slug segment.
|
|
19
|
+
*
|
|
20
|
+
* Matches Astro glob loader behavior:
|
|
21
|
+
* - lowercases the name
|
|
22
|
+
* - replaces whitespace runs with a single hyphen
|
|
23
|
+
*
|
|
24
|
+
* Examples:
|
|
25
|
+
* "Prompt Optimization" → "prompt-optimization"
|
|
26
|
+
* "Introduction" → "introduction"
|
|
27
|
+
* "getting-started" → "getting-started"
|
|
28
|
+
*/
|
|
29
|
+
export function slugify(name) {
|
|
30
|
+
return name.toLowerCase().replace(/\s+/g, "-");
|
|
31
|
+
}
|
|
17
32
|
/**
|
|
18
33
|
* Build a navigation tree by recursively walking a content directory.
|
|
19
34
|
*
|
|
@@ -87,10 +102,12 @@ async function buildItems(dirPath, slugPrefix) {
|
|
|
87
102
|
}
|
|
88
103
|
// Check if entry matches a directory
|
|
89
104
|
if (dirs.includes(entry.key)) {
|
|
90
|
-
const
|
|
105
|
+
const dirSlug = slugify(entry.key);
|
|
106
|
+
const fullSlug = slugPrefix ? `${slugPrefix}/${dirSlug}` : dirSlug;
|
|
107
|
+
const children = await buildItems(join(dirPath, entry.key), fullSlug);
|
|
91
108
|
result.push({
|
|
92
109
|
title: entry.title,
|
|
93
|
-
slug:
|
|
110
|
+
slug: fullSlug,
|
|
94
111
|
type: "section",
|
|
95
112
|
children,
|
|
96
113
|
...(entry.collapsed !== undefined && { collapsed: entry.collapsed }),
|
|
@@ -101,10 +118,10 @@ async function buildItems(dirPath, slugPrefix) {
|
|
|
101
118
|
// Check if entry matches a file
|
|
102
119
|
const matchingFile = files.find((f) => parse(f).name === entry.key);
|
|
103
120
|
if (matchingFile) {
|
|
104
|
-
const
|
|
121
|
+
const fileSlug = slugify(parse(matchingFile).name);
|
|
105
122
|
result.push({
|
|
106
123
|
title: entry.title,
|
|
107
|
-
slug: slugPrefix ? `${slugPrefix}/${
|
|
124
|
+
slug: slugPrefix ? `${slugPrefix}/${fileSlug}` : fileSlug,
|
|
108
125
|
type: "page",
|
|
109
126
|
...(entry.icon !== undefined && { icon: entry.icon }),
|
|
110
127
|
});
|
|
@@ -117,19 +134,22 @@ async function buildItems(dirPath, slugPrefix) {
|
|
|
117
134
|
.sort();
|
|
118
135
|
for (const file of unlistedFiles) {
|
|
119
136
|
const baseName = parse(file).name;
|
|
137
|
+
const fileSlug = slugify(baseName);
|
|
120
138
|
result.push({
|
|
121
139
|
title: toTitleCase(baseName),
|
|
122
|
-
slug: slugPrefix ? `${slugPrefix}/${
|
|
140
|
+
slug: slugPrefix ? `${slugPrefix}/${fileSlug}` : fileSlug,
|
|
123
141
|
type: "page",
|
|
124
142
|
});
|
|
125
143
|
}
|
|
126
144
|
// Append unlisted directories alphabetically
|
|
127
145
|
const unlistedDirs = dirs.filter((d) => !listedKeys.has(d)).sort();
|
|
128
146
|
for (const dir of unlistedDirs) {
|
|
129
|
-
const
|
|
147
|
+
const dirSlug = slugify(dir);
|
|
148
|
+
const fullSlug = slugPrefix ? `${slugPrefix}/${dirSlug}` : dirSlug;
|
|
149
|
+
const children = await buildItems(join(dirPath, dir), fullSlug);
|
|
130
150
|
result.push({
|
|
131
151
|
title: toTitleCase(dir),
|
|
132
|
-
slug:
|
|
152
|
+
slug: fullSlug,
|
|
133
153
|
type: "section",
|
|
134
154
|
children,
|
|
135
155
|
});
|
|
@@ -146,7 +166,8 @@ async function buildAlphabetical(files, dirs, dirPath, slugPrefix) {
|
|
|
146
166
|
].sort((a, b) => a.name.localeCompare(b.name));
|
|
147
167
|
const result = [];
|
|
148
168
|
for (const entry of allEntries) {
|
|
149
|
-
const
|
|
169
|
+
const entrySlug = slugify(entry.name);
|
|
170
|
+
const slug = slugPrefix ? `${slugPrefix}/${entrySlug}` : entrySlug;
|
|
150
171
|
if (entry.isDir) {
|
|
151
172
|
const children = await buildItems(join(dirPath, entry.name), slug);
|
|
152
173
|
result.push({
|
|
@@ -190,10 +190,12 @@ export function generatePythonExample(endpoint, options = DEFAULT_OPTIONS) {
|
|
|
190
190
|
* Convert JSON string to Python dict syntax (True/False/None).
|
|
191
191
|
*/
|
|
192
192
|
function pythonifyJson(json) {
|
|
193
|
+
// Only replace JSON values (not strings containing these words).
|
|
194
|
+
// Match `: true`, `: false`, `: null` when followed by comma, newline, or end-of-object/array.
|
|
193
195
|
return json
|
|
194
|
-
.replace(/: true/g, ": True")
|
|
195
|
-
.replace(/: false/g, ": False")
|
|
196
|
-
.replace(/: null/g, ": None");
|
|
196
|
+
.replace(/: true(?=[,\n\r\s}\]]|$)/g, ": True")
|
|
197
|
+
.replace(/: false(?=[,\n\r\s}\]]|$)/g, ": False")
|
|
198
|
+
.replace(/: null(?=[,\n\r\s}\]]|$)/g, ": None");
|
|
197
199
|
}
|
|
198
200
|
/* ------------------------------------------------------------------ */
|
|
199
201
|
/* Node.js Generator */
|
|
@@ -21,7 +21,14 @@ export function generateExampleValue(schema, depth = 0) {
|
|
|
21
21
|
if (depth > MAX_DEPTH) {
|
|
22
22
|
return getTypeDefault(schema.type);
|
|
23
23
|
}
|
|
24
|
-
// 1. Prefer
|
|
24
|
+
// 1. Prefer spec-provided example values (AC6)
|
|
25
|
+
if (schema.example !== undefined) {
|
|
26
|
+
return schema.example;
|
|
27
|
+
}
|
|
28
|
+
if (schema.examples && Array.isArray(schema.examples) && schema.examples.length > 0) {
|
|
29
|
+
return schema.examples[0];
|
|
30
|
+
}
|
|
31
|
+
// 2. Prefer enum first value
|
|
25
32
|
if (schema.enum && schema.enum.length > 0) {
|
|
26
33
|
return schema.enum[0];
|
|
27
34
|
}
|
package/dist/openapi/types.d.ts
CHANGED
|
@@ -16,6 +16,10 @@ export interface ApiSchema {
|
|
|
16
16
|
required?: string[];
|
|
17
17
|
enum?: unknown[];
|
|
18
18
|
description?: string;
|
|
19
|
+
/** Spec-provided example value for this schema */
|
|
20
|
+
example?: unknown;
|
|
21
|
+
/** Spec-provided array of example values for this schema */
|
|
22
|
+
examples?: unknown[];
|
|
19
23
|
/** Original $ref path, preserved for display (e.g., "#/components/schemas/Pet") */
|
|
20
24
|
ref?: string;
|
|
21
25
|
allOf?: ApiSchema[];
|
package/dist/openapi/utils.d.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* @module
|
|
8
8
|
*/
|
|
9
|
-
import type { ApiEndpoint } from "./types.js";
|
|
9
|
+
import type { ApiEndpoint, ApiEndpointError } from "./types.js";
|
|
10
10
|
/**
|
|
11
11
|
* Build a URL-friendly slug for an API endpoint.
|
|
12
12
|
*
|
|
@@ -17,6 +17,18 @@ import type { ApiEndpoint } from "./types.js";
|
|
|
17
17
|
* // → "pets/get-pets-petId"
|
|
18
18
|
*/
|
|
19
19
|
export declare function buildEndpointSlug(endpoint: ApiEndpoint): string;
|
|
20
|
+
/**
|
|
21
|
+
* Build a URL-friendly slug for an errored API endpoint.
|
|
22
|
+
*
|
|
23
|
+
* Uses `_unsupported/` prefix to avoid collisions with valid endpoint slugs.
|
|
24
|
+
*
|
|
25
|
+
* Format: `_unsupported/{method}-{sanitized-path}`
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* buildErrorEndpointSlug({ method: "get", path: "/pets/{petId}", reason: "...", rawSpec: {} })
|
|
29
|
+
* // → "_unsupported/get-pets-petId"
|
|
30
|
+
*/
|
|
31
|
+
export declare function buildErrorEndpointSlug(error: ApiEndpointError): string;
|
|
20
32
|
/**
|
|
21
33
|
* Build a unique identifier for an endpoint (used for active-state matching).
|
|
22
34
|
*
|
package/dist/openapi/utils.js
CHANGED
|
@@ -23,6 +23,24 @@ export function buildEndpointSlug(endpoint) {
|
|
|
23
23
|
.replace(/[{}]/g, "");
|
|
24
24
|
return `${tag}/${endpoint.method}-${sanitizedPath}`;
|
|
25
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* Build a URL-friendly slug for an errored API endpoint.
|
|
28
|
+
*
|
|
29
|
+
* Uses `_unsupported/` prefix to avoid collisions with valid endpoint slugs.
|
|
30
|
+
*
|
|
31
|
+
* Format: `_unsupported/{method}-{sanitized-path}`
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* buildErrorEndpointSlug({ method: "get", path: "/pets/{petId}", reason: "...", rawSpec: {} })
|
|
35
|
+
* // → "_unsupported/get-pets-petId"
|
|
36
|
+
*/
|
|
37
|
+
export function buildErrorEndpointSlug(error) {
|
|
38
|
+
const sanitizedPath = error.path
|
|
39
|
+
.replace(/^\//, "")
|
|
40
|
+
.replace(/\//g, "-")
|
|
41
|
+
.replace(/[{}]/g, "");
|
|
42
|
+
return `_unsupported/${error.method}-${sanitizedPath}`;
|
|
43
|
+
}
|
|
26
44
|
/**
|
|
27
45
|
* Build a unique identifier for an endpoint (used for active-state matching).
|
|
28
46
|
*
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frontmatter validator — validates MDX/MD file frontmatter against the content schema.
|
|
3
|
+
*
|
|
4
|
+
* Composable validator following the pattern established in link-validator.ts.
|
|
5
|
+
* Reuses the existing `frontmatterSchema` Zod schema from content/frontmatter-schema.ts.
|
|
6
|
+
*
|
|
7
|
+
* Error codes:
|
|
8
|
+
* FRONTMATTER_MISSING_FIELD — Required field not present
|
|
9
|
+
* FRONTMATTER_TYPE_MISMATCH — Field present but wrong type
|
|
10
|
+
* FRONTMATTER_UNKNOWN_FIELD — Field not in schema (warning)
|
|
11
|
+
* FRONTMATTER_PARSE_ERROR — YAML block is malformed
|
|
12
|
+
* FRONTMATTER_INVALID — Catch-all for other Zod issues
|
|
13
|
+
*/
|
|
14
|
+
import type { ValidationResult, ValidatorContext } from "./types.js";
|
|
15
|
+
/**
|
|
16
|
+
* Extract raw frontmatter string from file content.
|
|
17
|
+
*
|
|
18
|
+
* Frontmatter is the YAML block between the first pair of `---` delimiters
|
|
19
|
+
* at the top of the file.
|
|
20
|
+
*
|
|
21
|
+
* @returns The raw YAML string, or null if no frontmatter block found.
|
|
22
|
+
*/
|
|
23
|
+
export declare function extractFrontmatterRaw(content: string): string | null;
|
|
24
|
+
/**
|
|
25
|
+
* Parse a raw YAML string into a plain object.
|
|
26
|
+
*
|
|
27
|
+
* Uses js-yaml (available as transitive dependency) for YAML parsing.
|
|
28
|
+
*
|
|
29
|
+
* @throws Error with a descriptive message if YAML is malformed.
|
|
30
|
+
*/
|
|
31
|
+
export declare function parseFrontmatterYaml(raw: string): Record<string, unknown>;
|
|
32
|
+
/**
|
|
33
|
+
* Detect unknown frontmatter fields not in the schema.
|
|
34
|
+
*
|
|
35
|
+
* Fields starting with `_` (Astro internals) are ignored.
|
|
36
|
+
* Unknown fields are reported as warnings, not errors.
|
|
37
|
+
*/
|
|
38
|
+
export declare function detectUnknownFields(frontmatter: Record<string, unknown>, filePath: string): ValidationResult[];
|
|
39
|
+
/**
|
|
40
|
+
* Validate a single file's frontmatter against the schema.
|
|
41
|
+
*
|
|
42
|
+
* Returns an array of ValidationResult for all issues found.
|
|
43
|
+
*/
|
|
44
|
+
export declare function validateSingleFile(filePath: string): Promise<ValidationResult[]>;
|
|
45
|
+
/**
|
|
46
|
+
* Composable frontmatter validator.
|
|
47
|
+
*
|
|
48
|
+
* Discovers all MDX/MD files in the content directory and validates
|
|
49
|
+
* each file's frontmatter against the Zod content schema.
|
|
50
|
+
*
|
|
51
|
+
* @param context - Validator context with contentDir
|
|
52
|
+
* @returns Array of ValidationResult for all frontmatter issues
|
|
53
|
+
*/
|
|
54
|
+
export declare function validateFrontmatter(context: ValidatorContext): Promise<ValidationResult[]>;
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frontmatter validator — validates MDX/MD file frontmatter against the content schema.
|
|
3
|
+
*
|
|
4
|
+
* Composable validator following the pattern established in link-validator.ts.
|
|
5
|
+
* Reuses the existing `frontmatterSchema` Zod schema from content/frontmatter-schema.ts.
|
|
6
|
+
*
|
|
7
|
+
* Error codes:
|
|
8
|
+
* FRONTMATTER_MISSING_FIELD — Required field not present
|
|
9
|
+
* FRONTMATTER_TYPE_MISMATCH — Field present but wrong type
|
|
10
|
+
* FRONTMATTER_UNKNOWN_FIELD — Field not in schema (warning)
|
|
11
|
+
* FRONTMATTER_PARSE_ERROR — YAML block is malformed
|
|
12
|
+
* FRONTMATTER_INVALID — Catch-all for other Zod issues
|
|
13
|
+
*/
|
|
14
|
+
import { readFile } from "node:fs/promises";
|
|
15
|
+
import yaml from "js-yaml";
|
|
16
|
+
import { frontmatterSchema } from "../content/frontmatter-schema.js";
|
|
17
|
+
import { discoverContentFiles } from "./link-validator.js";
|
|
18
|
+
/**
|
|
19
|
+
* Known frontmatter schema keys, derived from the Zod schema shape.
|
|
20
|
+
* Keys starting with `_` (Astro internals like `_id`, `_collection`) are always allowed.
|
|
21
|
+
*/
|
|
22
|
+
const KNOWN_KEYS = new Set(Object.keys(frontmatterSchema.shape));
|
|
23
|
+
/**
|
|
24
|
+
* Extract raw frontmatter string from file content.
|
|
25
|
+
*
|
|
26
|
+
* Frontmatter is the YAML block between the first pair of `---` delimiters
|
|
27
|
+
* at the top of the file.
|
|
28
|
+
*
|
|
29
|
+
* @returns The raw YAML string, or null if no frontmatter block found.
|
|
30
|
+
*/
|
|
31
|
+
export function extractFrontmatterRaw(content) {
|
|
32
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
33
|
+
return match ? match[1] : null;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Parse a raw YAML string into a plain object.
|
|
37
|
+
*
|
|
38
|
+
* Uses js-yaml (available as transitive dependency) for YAML parsing.
|
|
39
|
+
*
|
|
40
|
+
* @throws Error with a descriptive message if YAML is malformed.
|
|
41
|
+
*/
|
|
42
|
+
export function parseFrontmatterYaml(raw) {
|
|
43
|
+
const parsed = yaml.load(raw);
|
|
44
|
+
if (parsed === null || parsed === undefined) {
|
|
45
|
+
return {};
|
|
46
|
+
}
|
|
47
|
+
if (typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
48
|
+
throw new Error("Frontmatter must be a YAML mapping, not a scalar or list");
|
|
49
|
+
}
|
|
50
|
+
return parsed;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Map a single Zod issue to a ValidationResult.
|
|
54
|
+
*/
|
|
55
|
+
function zodIssueToResult(issue, filePath) {
|
|
56
|
+
const fieldPath = issue.path.join(".") || "unknown";
|
|
57
|
+
// Missing required field
|
|
58
|
+
if (issue.code === "invalid_type" && issue.received === "undefined") {
|
|
59
|
+
const hint = fieldPath === "title"
|
|
60
|
+
? 'Field \'title\' is required — add `title: "Page Title"` to your frontmatter'
|
|
61
|
+
: `Field '${fieldPath}' is required`;
|
|
62
|
+
return {
|
|
63
|
+
code: "FRONTMATTER_MISSING_FIELD",
|
|
64
|
+
message: `Missing required field '${fieldPath}' in ${filePath}`,
|
|
65
|
+
filePath,
|
|
66
|
+
line: 1,
|
|
67
|
+
severity: "error",
|
|
68
|
+
hint,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
// Type mismatch
|
|
72
|
+
if (issue.code === "invalid_type") {
|
|
73
|
+
return {
|
|
74
|
+
code: "FRONTMATTER_TYPE_MISMATCH",
|
|
75
|
+
message: `Type mismatch for field '${fieldPath}' in ${filePath}: expected ${issue.expected}, got ${issue.received}`,
|
|
76
|
+
filePath,
|
|
77
|
+
line: 1,
|
|
78
|
+
severity: "error",
|
|
79
|
+
hint: `Field '${fieldPath}' expected type '${issue.expected}', got '${issue.received}'`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
// Catch-all for other Zod issues
|
|
83
|
+
return {
|
|
84
|
+
code: "FRONTMATTER_INVALID",
|
|
85
|
+
message: `Invalid field '${fieldPath}' in ${filePath}: ${issue.message}`,
|
|
86
|
+
filePath,
|
|
87
|
+
line: 1,
|
|
88
|
+
severity: "error",
|
|
89
|
+
hint: `Field '${fieldPath}': ${issue.message}`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Detect unknown frontmatter fields not in the schema.
|
|
94
|
+
*
|
|
95
|
+
* Fields starting with `_` (Astro internals) are ignored.
|
|
96
|
+
* Unknown fields are reported as warnings, not errors.
|
|
97
|
+
*/
|
|
98
|
+
export function detectUnknownFields(frontmatter, filePath) {
|
|
99
|
+
const results = [];
|
|
100
|
+
for (const key of Object.keys(frontmatter)) {
|
|
101
|
+
if (key.startsWith("_"))
|
|
102
|
+
continue;
|
|
103
|
+
if (!KNOWN_KEYS.has(key)) {
|
|
104
|
+
results.push({
|
|
105
|
+
code: "FRONTMATTER_UNKNOWN_FIELD",
|
|
106
|
+
message: `Unknown frontmatter field '${key}' in ${filePath}`,
|
|
107
|
+
filePath,
|
|
108
|
+
line: 1,
|
|
109
|
+
severity: "warning",
|
|
110
|
+
hint: `Consider moving to \`custom.${key}\` for forward compatibility`,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return results;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Validate a single file's frontmatter against the schema.
|
|
118
|
+
*
|
|
119
|
+
* Returns an array of ValidationResult for all issues found.
|
|
120
|
+
*/
|
|
121
|
+
export async function validateSingleFile(filePath) {
|
|
122
|
+
const results = [];
|
|
123
|
+
let content;
|
|
124
|
+
try {
|
|
125
|
+
content = await readFile(filePath, "utf-8");
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
results.push({
|
|
129
|
+
code: "FRONTMATTER_PARSE_ERROR",
|
|
130
|
+
message: `Cannot read file: ${filePath}`,
|
|
131
|
+
filePath,
|
|
132
|
+
line: 1,
|
|
133
|
+
severity: "error",
|
|
134
|
+
hint: "Verify the file exists and is readable",
|
|
135
|
+
});
|
|
136
|
+
return results;
|
|
137
|
+
}
|
|
138
|
+
// Extract frontmatter block
|
|
139
|
+
const raw = extractFrontmatterRaw(content);
|
|
140
|
+
if (raw === null) {
|
|
141
|
+
// No frontmatter block — report missing required title
|
|
142
|
+
results.push({
|
|
143
|
+
code: "FRONTMATTER_MISSING_FIELD",
|
|
144
|
+
message: `No frontmatter block found in ${filePath}`,
|
|
145
|
+
filePath,
|
|
146
|
+
line: 1,
|
|
147
|
+
severity: "error",
|
|
148
|
+
hint: 'Add a frontmatter block with at least `title`: `---\ntitle: "Page Title"\n---`',
|
|
149
|
+
});
|
|
150
|
+
return results;
|
|
151
|
+
}
|
|
152
|
+
// Parse YAML
|
|
153
|
+
let parsed;
|
|
154
|
+
try {
|
|
155
|
+
parsed = parseFrontmatterYaml(raw);
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
const message = err instanceof Error ? err.message : "Unknown YAML parse error";
|
|
159
|
+
results.push({
|
|
160
|
+
code: "FRONTMATTER_PARSE_ERROR",
|
|
161
|
+
message: `Malformed frontmatter in ${filePath}: ${message}`,
|
|
162
|
+
filePath,
|
|
163
|
+
line: 1,
|
|
164
|
+
severity: "error",
|
|
165
|
+
hint: "Fix the YAML syntax in the frontmatter block",
|
|
166
|
+
});
|
|
167
|
+
return results;
|
|
168
|
+
}
|
|
169
|
+
// Validate against Zod schema
|
|
170
|
+
const zodResult = frontmatterSchema.safeParse(parsed);
|
|
171
|
+
if (!zodResult.success) {
|
|
172
|
+
for (const issue of zodResult.error.issues) {
|
|
173
|
+
results.push(zodIssueToResult(issue, filePath));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// Detect unknown fields (even if Zod validation passed due to .passthrough())
|
|
177
|
+
results.push(...detectUnknownFields(parsed, filePath));
|
|
178
|
+
return results;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Composable frontmatter validator.
|
|
182
|
+
*
|
|
183
|
+
* Discovers all MDX/MD files in the content directory and validates
|
|
184
|
+
* each file's frontmatter against the Zod content schema.
|
|
185
|
+
*
|
|
186
|
+
* @param context - Validator context with contentDir
|
|
187
|
+
* @returns Array of ValidationResult for all frontmatter issues
|
|
188
|
+
*/
|
|
189
|
+
export async function validateFrontmatter(context) {
|
|
190
|
+
const files = await discoverContentFiles(context.contentDir);
|
|
191
|
+
const results = [];
|
|
192
|
+
for (const filePath of files) {
|
|
193
|
+
const fileResults = await validateSingleFile(filePath);
|
|
194
|
+
results.push(...fileResults);
|
|
195
|
+
}
|
|
196
|
+
return results;
|
|
197
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation runner — collects results from composable validators.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* const results = await runValidators([validateLinks, validateFrontmatter], context);
|
|
6
|
+
*/
|
|
7
|
+
import type { ValidationResult, ValidatorContext, Validator } from "./types.js";
|
|
8
|
+
export { type ValidationResult, type ValidationSeverity, type ValidatorContext, type Validator, toSpecglassError } from "./types.js";
|
|
9
|
+
export { validateFrontmatter } from "./frontmatter-validator.js";
|
|
10
|
+
export { validateSpecDrift } from "./spec-drift-validator.js";
|
|
11
|
+
/**
|
|
12
|
+
* Run multiple validators and collect all results.
|
|
13
|
+
*
|
|
14
|
+
* Each validator runs independently and returns its findings.
|
|
15
|
+
* Results are concatenated in validator order.
|
|
16
|
+
*/
|
|
17
|
+
export declare function runValidators(validators: Validator[], context: ValidatorContext): Promise<ValidationResult[]>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export { toSpecglassError } from "./types.js";
|
|
2
|
+
export { validateFrontmatter } from "./frontmatter-validator.js";
|
|
3
|
+
export { validateSpecDrift } from "./spec-drift-validator.js";
|
|
4
|
+
/**
|
|
5
|
+
* Run multiple validators and collect all results.
|
|
6
|
+
*
|
|
7
|
+
* Each validator runs independently and returns its findings.
|
|
8
|
+
* Results are concatenated in validator order.
|
|
9
|
+
*/
|
|
10
|
+
export async function runValidators(validators, context) {
|
|
11
|
+
const results = [];
|
|
12
|
+
for (const validator of validators) {
|
|
13
|
+
const findings = await validator(context);
|
|
14
|
+
results.push(...findings);
|
|
15
|
+
}
|
|
16
|
+
return results;
|
|
17
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { ValidationResult, ValidatorContext } from "./types.js";
|
|
2
|
+
export declare const LINK_INTERNAL_BROKEN = "LINK_INTERNAL_BROKEN";
|
|
3
|
+
export declare const LINK_INTERNAL_ANCHOR_BROKEN = "LINK_INTERNAL_ANCHOR_BROKEN";
|
|
4
|
+
export declare const LINK_EXTERNAL_UNREACHABLE = "LINK_EXTERNAL_UNREACHABLE";
|
|
5
|
+
/** A link extracted from source content. */
|
|
6
|
+
export interface ExtractedLink {
|
|
7
|
+
/** The raw href/URL from the link. */
|
|
8
|
+
target: string;
|
|
9
|
+
/** 1-indexed line number in the source file. */
|
|
10
|
+
line: number;
|
|
11
|
+
/** Whether this is an external (http/https) link. */
|
|
12
|
+
isExternal: boolean;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Extract all links from MDX/MD content string.
|
|
16
|
+
*
|
|
17
|
+
* Finds both markdown links `[text](url)` and HTML `<a href="url">` links.
|
|
18
|
+
* Returns line numbers for each extracted link.
|
|
19
|
+
*/
|
|
20
|
+
export declare function extractLinks(content: string): ExtractedLink[];
|
|
21
|
+
/** Check if a URL is external (http:// or https://). */
|
|
22
|
+
export declare function isExternalLink(target: string): boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Resolve and validate all internal links in a single file.
|
|
25
|
+
*/
|
|
26
|
+
export declare function resolveInternalLinks(links: ExtractedLink[], sourceFilePath: string, contentDir: string, knownPages: string[]): Promise<ValidationResult[]>;
|
|
27
|
+
/** Resolve a link path to an absolute file path. */
|
|
28
|
+
export declare function resolvePagePath(linkPath: string, sourceFilePath: string, contentDir: string): string;
|
|
29
|
+
/**
|
|
30
|
+
* Check if a resolved path corresponds to an existing content file.
|
|
31
|
+
* Handles extension normalization (.mdx/.md), index files, and trailing slashes.
|
|
32
|
+
*/
|
|
33
|
+
export declare function checkFileExists(resolvedPath: string, contentDir: string, _knownPages: string[]): Promise<{
|
|
34
|
+
exists: boolean;
|
|
35
|
+
resolvedPath?: string;
|
|
36
|
+
}>;
|
|
37
|
+
/**
|
|
38
|
+
* Check if a heading anchor exists in a markdown file.
|
|
39
|
+
* Extracts headings and converts them to slug IDs.
|
|
40
|
+
*/
|
|
41
|
+
export declare function checkAnchorInFile(anchor: string, filePath: string, sourceLine: number, sourceFilePath?: string): Promise<ValidationResult | null>;
|
|
42
|
+
/**
|
|
43
|
+
* Extract heading IDs from markdown content.
|
|
44
|
+
* Converts headings to GitHub-style slug IDs.
|
|
45
|
+
* Filters out headings that appear inside fenced code blocks.
|
|
46
|
+
*/
|
|
47
|
+
export declare function extractHeadingIds(content: string): string[];
|
|
48
|
+
/**
|
|
49
|
+
* Convert a heading text to a GitHub-style anchor slug.
|
|
50
|
+
* e.g., "Getting Started" → "getting-started"
|
|
51
|
+
*/
|
|
52
|
+
export declare function headingToSlug(text: string): string;
|
|
53
|
+
/**
|
|
54
|
+
* Check external links for reachability via HTTP HEAD requests.
|
|
55
|
+
* Failures are reported as warnings, not errors.
|
|
56
|
+
*/
|
|
57
|
+
export declare function checkExternalLinks(links: ExtractedLink[], sourceFilePath: string): Promise<ValidationResult[]>;
|
|
58
|
+
/**
|
|
59
|
+
* Check a single external link via HTTP HEAD request.
|
|
60
|
+
* Returns a ValidationResult for unreachable links, null for valid ones.
|
|
61
|
+
*/
|
|
62
|
+
export declare function checkSingleExternalLink(link: ExtractedLink, sourceFilePath: string): Promise<ValidationResult | null>;
|
|
63
|
+
/**
|
|
64
|
+
* Find a suggested page for a broken internal link.
|
|
65
|
+
* Simple string distance comparison against known pages.
|
|
66
|
+
*/
|
|
67
|
+
export declare function findSuggestion(brokenPath: string, knownPages: string[]): string | null;
|
|
68
|
+
/** Simple Levenshtein distance implementation. */
|
|
69
|
+
export declare function levenshtein(a: string, b: string): number;
|
|
70
|
+
/**
|
|
71
|
+
* Recursively discover all .md/.mdx files in a directory.
|
|
72
|
+
*/
|
|
73
|
+
export declare function discoverContentFiles(dir: string): Promise<string[]>;
|
|
74
|
+
/**
|
|
75
|
+
* Build the list of known page slugs from the content directory.
|
|
76
|
+
*/
|
|
77
|
+
export declare function buildKnownPages(contentFiles: string[], contentDir: string): string[];
|
|
78
|
+
/**
|
|
79
|
+
* Link validator — the composable validator function for link checking.
|
|
80
|
+
*
|
|
81
|
+
* @param context - Validator context with contentDir and options
|
|
82
|
+
* @returns Array of validation results (errors for internal, warnings for external)
|
|
83
|
+
*/
|
|
84
|
+
export declare function validateLinks(context: ValidatorContext): Promise<ValidationResult[]>;
|