radiant-docs-validator 0.1.0 → 0.1.4

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/index.d.ts CHANGED
@@ -163,8 +163,37 @@ type Footer = {
163
163
  socials?: Partial<Record<SocialPlatform, string>>;
164
164
  links?: FooterLink[];
165
165
  };
166
+ type DocsHrefResolution = {
167
+ kind: "ignored";
168
+ } | {
169
+ kind: "invalid";
170
+ reason: "not-root-absolute" | "generated-route" | "empty-target" | "path-traversal";
171
+ href: string;
172
+ pathname: string;
173
+ suffix: string;
174
+ filePath: string;
175
+ suggestedHref?: string;
176
+ } | {
177
+ kind: "docs-page";
178
+ href: string;
179
+ pathname: string;
180
+ suffix: string;
181
+ filePath: string;
182
+ } | {
183
+ kind: "local-asset";
184
+ href: string;
185
+ pathname: string;
186
+ suffix: string;
187
+ filePath: string;
188
+ extension: string;
189
+ publishable: boolean;
190
+ };
166
191
  declare function loadOpenApiSpec(filePathOrUrl: string): Promise<any>;
167
192
  declare function getConfig(): Promise<DocsConfig>;
193
+ declare const PUBLISHABLE_STATIC_ASSET_EXTENSIONS: readonly [".avif", ".csv", ".doc", ".docx", ".gif", ".ico", ".jpeg", ".jpg", ".mp3", ".mp4", ".ogg", ".pdf", ".png", ".ppt", ".pptx", ".svg", ".tsv", ".txt", ".wav", ".webm", ".webp", ".xls", ".xlsx", ".zip"];
194
+ declare function isPublishableStaticAssetPath(filePath: string): boolean;
195
+ declare function resolveDocsHref(href: string): DocsHrefResolution;
196
+ declare const resolveDocsPageHref: typeof resolveDocsHref;
168
197
  declare function validateMdxContent(): Promise<void>;
169
198
 
170
- export { type AssistantButtonConfig, type AssistantButtonSize, type AssistantConfig, type AssistantIcon, type AssistantNavbarButtonConfig, BASE_COLOR_OPTIONS, type BaseColorByMode, type BaseColorOption, type CardButtonTheme, type CardCoverTheme, type CardTheme, type CodeSyntaxThemeConfig, type CodeTheme, DEFAULT_THEME_COLOR_DARK, DEFAULT_THEME_COLOR_LIGHT, type DocsConfig, type DocsTheme, type DocsValidatorOptions, type Footer, type FooterLink, type HiddenPageRoute, type Logo, type LogoVariant, type NavGroup, type NavMenu, type NavMenuItem, type NavOpenApi, type NavOpenApiPage, type NavOpenApiPageRef, type NavPage, type NavTag, type NavbarItem, type NavigationItem, type SocialPlatform, type TagTheme, type ThemeColorByMode, configureDocsValidator, getConfig, loadOpenApiSpec, validateMdxContent };
199
+ export { type AssistantButtonConfig, type AssistantButtonSize, type AssistantConfig, type AssistantIcon, type AssistantNavbarButtonConfig, BASE_COLOR_OPTIONS, type BaseColorByMode, type BaseColorOption, type CardButtonTheme, type CardCoverTheme, type CardTheme, type CodeSyntaxThemeConfig, type CodeTheme, DEFAULT_THEME_COLOR_DARK, DEFAULT_THEME_COLOR_LIGHT, type DocsConfig, type DocsHrefResolution, type DocsTheme, type DocsValidatorOptions, type Footer, type FooterLink, type HiddenPageRoute, type Logo, type LogoVariant, type NavGroup, type NavMenu, type NavMenuItem, type NavOpenApi, type NavOpenApiPage, type NavOpenApiPageRef, type NavPage, type NavTag, type NavbarItem, type NavigationItem, PUBLISHABLE_STATIC_ASSET_EXTENSIONS, type SocialPlatform, type TagTheme, type ThemeColorByMode, configureDocsValidator, getConfig, isPublishableStaticAssetPath, loadOpenApiSpec, resolveDocsHref, resolveDocsPageHref, validateMdxContent };
package/dist/index.js CHANGED
@@ -289,6 +289,19 @@ function validateFileExistence(filePath, currentPath) {
289
289
  );
290
290
  }
291
291
  }
292
+ function normalizeDocsRootFilePath(value) {
293
+ let normalized = value.replace(/\\/g, "/").trim();
294
+ if (!normalized) return "";
295
+ normalized = normalized.replace(/^\/+/, "").replace(/\/+$/, "");
296
+ const posixNormalized = path.posix.normalize(`/${normalized}`).replace(/^\/+/, "");
297
+ return posixNormalized === "." ? "" : posixNormalized;
298
+ }
299
+ function normalizeDocsFilePath(value) {
300
+ return normalizeDocsRootFilePath(value).replace(/\.(md|mdx)$/i, "");
301
+ }
302
+ function containsPathTraversal(value) {
303
+ return value.replace(/\\/g, "/").split("/").includes("..");
304
+ }
292
305
  function normalizeDocsPagePath(value, currentPath, label = "Page path") {
293
306
  checkType(value, "string", currentPath, label);
294
307
  const trimmedPath = value.trim();
@@ -1975,6 +1988,273 @@ function validateComponentUsage(content) {
1975
1988
  );
1976
1989
  }
1977
1990
  }
1991
+ var EXTERNAL_PROTOCOL_REGEX = /^[a-zA-Z][a-zA-Z\d+\-.]*:/;
1992
+ var DOCS_PAGE_SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([".md", ".mdx"]);
1993
+ var DOCS_PAGE_ROUTE_EXTENSIONS = /* @__PURE__ */ new Set([".htm", ".html"]);
1994
+ var PUBLISHABLE_STATIC_ASSET_EXTENSIONS = [
1995
+ ".avif",
1996
+ ".csv",
1997
+ ".doc",
1998
+ ".docx",
1999
+ ".gif",
2000
+ ".ico",
2001
+ ".jpeg",
2002
+ ".jpg",
2003
+ ".mp3",
2004
+ ".mp4",
2005
+ ".ogg",
2006
+ ".pdf",
2007
+ ".png",
2008
+ ".ppt",
2009
+ ".pptx",
2010
+ ".svg",
2011
+ ".tsv",
2012
+ ".txt",
2013
+ ".wav",
2014
+ ".webm",
2015
+ ".webp",
2016
+ ".xls",
2017
+ ".xlsx",
2018
+ ".zip"
2019
+ ];
2020
+ var PUBLISHABLE_STATIC_ASSET_EXTENSION_SET = new Set(
2021
+ PUBLISHABLE_STATIC_ASSET_EXTENSIONS
2022
+ );
2023
+ function isIgnoredHref(pathname) {
2024
+ if (!pathname) return true;
2025
+ if (pathname.startsWith("#")) return true;
2026
+ if (pathname.startsWith("?")) return true;
2027
+ if (pathname.startsWith("//")) return true;
2028
+ return EXTERNAL_PROTOCOL_REGEX.test(pathname);
2029
+ }
2030
+ function getHrefExtension(pathname) {
2031
+ return path.posix.extname(pathname.replace(/\\/g, "/")).toLowerCase();
2032
+ }
2033
+ function isPublishableStaticAssetPath(filePath) {
2034
+ return PUBLISHABLE_STATIC_ASSET_EXTENSION_SET.has(getHrefExtension(filePath));
2035
+ }
2036
+ function resolveDocsHref(href) {
2037
+ const { pathname, suffix } = splitHrefPathAndSuffix(href);
2038
+ const baseHref = pathname.trim();
2039
+ if (isIgnoredHref(baseHref)) {
2040
+ return { kind: "ignored" };
2041
+ }
2042
+ const normalizedRootFilePath = normalizeDocsRootFilePath(baseHref);
2043
+ const baseResult = {
2044
+ href,
2045
+ pathname: baseHref,
2046
+ suffix,
2047
+ filePath: normalizedRootFilePath
2048
+ };
2049
+ if (containsPathTraversal(baseHref)) {
2050
+ return {
2051
+ kind: "invalid",
2052
+ reason: "path-traversal",
2053
+ ...baseResult
2054
+ };
2055
+ }
2056
+ if (!baseHref.startsWith("/")) {
2057
+ return {
2058
+ kind: "invalid",
2059
+ reason: "not-root-absolute",
2060
+ ...baseResult,
2061
+ suggestedHref: normalizedRootFilePath ? `/${normalizedRootFilePath}` : void 0
2062
+ };
2063
+ }
2064
+ if (!normalizedRootFilePath) {
2065
+ return {
2066
+ kind: "invalid",
2067
+ reason: "empty-target",
2068
+ ...baseResult
2069
+ };
2070
+ }
2071
+ const extension = getHrefExtension(baseHref);
2072
+ if (DOCS_PAGE_ROUTE_EXTENSIONS.has(extension)) {
2073
+ return {
2074
+ kind: "invalid",
2075
+ reason: "generated-route",
2076
+ ...baseResult
2077
+ };
2078
+ }
2079
+ if (!extension || DOCS_PAGE_SOURCE_EXTENSIONS.has(extension)) {
2080
+ return {
2081
+ kind: "docs-page",
2082
+ ...baseResult,
2083
+ filePath: normalizeDocsFilePath(baseHref)
2084
+ };
2085
+ }
2086
+ return {
2087
+ kind: "local-asset",
2088
+ ...baseResult,
2089
+ extension,
2090
+ publishable: isPublishableStaticAssetPath(normalizedRootFilePath)
2091
+ };
2092
+ }
2093
+ var resolveDocsPageHref = resolveDocsHref;
2094
+ function addRoutableDocsFilePath(routableFilePaths, filePath) {
2095
+ if (typeof filePath !== "string") return;
2096
+ const normalizedFilePath = normalizeDocsFilePath(filePath);
2097
+ if (normalizedFilePath) {
2098
+ routableFilePaths.add(normalizedFilePath);
2099
+ }
2100
+ }
2101
+ function collectRoutablePageItems(routableFilePaths, items) {
2102
+ if (!Array.isArray(items)) return;
2103
+ for (const item of items) {
2104
+ if (typeof item === "string") {
2105
+ addRoutableDocsFilePath(routableFilePaths, item);
2106
+ continue;
2107
+ }
2108
+ if (!item || typeof item !== "object") continue;
2109
+ if ("page" in item) {
2110
+ addRoutableDocsFilePath(routableFilePaths, item.page);
2111
+ continue;
2112
+ }
2113
+ if ("group" in item) {
2114
+ collectRoutablePageItems(
2115
+ routableFilePaths,
2116
+ item.pages
2117
+ );
2118
+ }
2119
+ }
2120
+ }
2121
+ function collectRoutableNavigationPages(routableFilePaths, config) {
2122
+ collectRoutablePageItems(
2123
+ routableFilePaths,
2124
+ config.navigation.pages
2125
+ );
2126
+ for (const item of config.navigation.menu?.items ?? []) {
2127
+ collectRoutablePageItems(
2128
+ routableFilePaths,
2129
+ item.submenu.pages
2130
+ );
2131
+ }
2132
+ }
2133
+ function buildDocsLinkIndex(config, files) {
2134
+ const allDocsFilePaths = /* @__PURE__ */ new Set();
2135
+ const routableDocsFilePaths = /* @__PURE__ */ new Set();
2136
+ for (const file of files) {
2137
+ const relativePath = path.relative(DOCS_DIR, file);
2138
+ const normalizedFilePath = normalizeDocsFilePath(relativePath);
2139
+ if (normalizedFilePath) {
2140
+ allDocsFilePaths.add(normalizedFilePath);
2141
+ }
2142
+ }
2143
+ collectRoutableNavigationPages(routableDocsFilePaths, config);
2144
+ addRoutableDocsFilePath(routableDocsFilePaths, config.home);
2145
+ for (const route of config.hiddenPageRoutes ?? []) {
2146
+ addRoutableDocsFilePath(routableDocsFilePaths, route.filePath);
2147
+ }
2148
+ return {
2149
+ allDocsFilePaths,
2150
+ routableDocsFilePaths
2151
+ };
2152
+ }
2153
+ function validateDocsRootAbsoluteHref(args) {
2154
+ const resolvedHref = resolveDocsHref(args.href);
2155
+ if (resolvedHref.kind === "ignored") return;
2156
+ if (resolvedHref.kind === "invalid") {
2157
+ if (resolvedHref.reason === "not-root-absolute") {
2158
+ const suggestion = resolvedHref.suggestedHref ? ` Use "${resolvedHref.suggestedHref}" instead.` : "";
2159
+ throw new Error(
2160
+ `Invalid internal link "${args.href}" in ${args.sourceFile}. Docs page links must be docs-root absolute paths like "/guides/quickstart".${suggestion}`
2161
+ );
2162
+ }
2163
+ if (resolvedHref.reason === "generated-route") {
2164
+ throw new Error(
2165
+ `Invalid internal link "${args.href}" in ${args.sourceFile}. Docs page links must point to MDX source paths from the docs root, not generated HTML routes. Use a path like "/guides/quickstart" or "/guides/quickstart.mdx".`
2166
+ );
2167
+ }
2168
+ if (resolvedHref.reason === "path-traversal") {
2169
+ throw new Error(
2170
+ `Invalid local URL "${args.href}" in ${args.sourceFile}. Local URLs cannot contain ".." path traversal segments.`
2171
+ );
2172
+ }
2173
+ throw new Error(
2174
+ `Invalid internal link "${args.href}" in ${args.sourceFile}. Docs page links must point to a docs page file from the docs root, such as "/guides/quickstart".`
2175
+ );
2176
+ }
2177
+ if (resolvedHref.kind === "local-asset") {
2178
+ const fullAssetPath = path.join(DOCS_DIR, resolvedHref.filePath);
2179
+ if (!fs.existsSync(fullAssetPath) || !fs.statSync(fullAssetPath).isFile()) {
2180
+ throw new Error(
2181
+ `Invalid local asset "${args.href}" in ${args.sourceFile}. No matching file was found. Expected "${resolvedHref.filePath}" under the docs root.`
2182
+ );
2183
+ }
2184
+ if (!resolvedHref.publishable) {
2185
+ throw new Error(
2186
+ `Invalid local asset "${args.href}" in ${args.sourceFile}. Files with extension "${resolvedHref.extension}" are not published as docs assets.`
2187
+ );
2188
+ }
2189
+ return;
2190
+ }
2191
+ if (args.expectedTarget === "asset") {
2192
+ throw new Error(
2193
+ `Invalid local asset "${args.href}" in ${args.sourceFile}. Asset references must include a supported file extension.`
2194
+ );
2195
+ }
2196
+ if (args.linkIndex.routableDocsFilePaths.has(resolvedHref.filePath)) {
2197
+ return;
2198
+ }
2199
+ if (args.linkIndex.allDocsFilePaths.has(resolvedHref.filePath)) {
2200
+ throw new Error(
2201
+ `Invalid internal link "${args.href}" in ${args.sourceFile}. The target file exists ("${resolvedHref.filePath}.mdx"), but it is not a routable docs page. Add it to docs.json navigation, set it as the home page, or link to it from the navbar or footer so it is included in the built site.`
2202
+ );
2203
+ }
2204
+ throw new Error(
2205
+ `Invalid internal link "${args.href}" in ${args.sourceFile}. No matching docs page file was found. Expected "${resolvedHref.filePath}.mdx" under the docs root.`
2206
+ );
2207
+ }
2208
+ function visitMdxTree(node, visitor) {
2209
+ if (!node || typeof node !== "object") return;
2210
+ const typedNode = node;
2211
+ visitor(node);
2212
+ if (!Array.isArray(typedNode.children)) return;
2213
+ for (const child of typedNode.children) {
2214
+ visitMdxTree(child, visitor);
2215
+ }
2216
+ }
2217
+ function createInternalLinkValidationPlugin(args) {
2218
+ return () => (tree) => {
2219
+ visitMdxTree(tree, (node) => {
2220
+ if ((node.type === "link" || node.type === "definition") && typeof node.url === "string") {
2221
+ validateDocsRootAbsoluteHref({
2222
+ href: node.url,
2223
+ sourceFile: args.sourceFile,
2224
+ linkIndex: args.linkIndex,
2225
+ expectedTarget: "link"
2226
+ });
2227
+ }
2228
+ if (node.type === "image" && typeof node.url === "string") {
2229
+ validateDocsRootAbsoluteHref({
2230
+ href: node.url,
2231
+ sourceFile: args.sourceFile,
2232
+ linkIndex: args.linkIndex,
2233
+ expectedTarget: "asset"
2234
+ });
2235
+ }
2236
+ if (node.type !== "mdxJsxFlowElement" && node.type !== "mdxJsxTextElement") {
2237
+ return;
2238
+ }
2239
+ const element = node;
2240
+ if (!Array.isArray(element.attributes)) return;
2241
+ for (const attribute of element.attributes) {
2242
+ const hrefAttribute = attribute;
2243
+ if (hrefAttribute.type !== "mdxJsxAttribute") continue;
2244
+ if (hrefAttribute.name !== "href" && hrefAttribute.name !== "src") {
2245
+ continue;
2246
+ }
2247
+ if (typeof hrefAttribute.value !== "string") continue;
2248
+ validateDocsRootAbsoluteHref({
2249
+ href: hrefAttribute.value,
2250
+ sourceFile: args.sourceFile,
2251
+ linkIndex: args.linkIndex,
2252
+ expectedTarget: hrefAttribute.name === "src" ? "asset" : "link"
2253
+ });
2254
+ }
2255
+ });
2256
+ };
2257
+ }
1978
2258
  function getMdxFiles(dir) {
1979
2259
  let results = [];
1980
2260
  const list = fs.readdirSync(dir);
@@ -1983,7 +2263,7 @@ function getMdxFiles(dir) {
1983
2263
  const stat = fs.statSync(file);
1984
2264
  if (stat && stat.isDirectory()) {
1985
2265
  results = results.concat(getMdxFiles(file));
1986
- } else if (file.endsWith(".mdx")) {
2266
+ } else if (/\.(md|mdx)$/i.test(file)) {
1987
2267
  results.push(file);
1988
2268
  }
1989
2269
  });
@@ -1991,8 +2271,11 @@ function getMdxFiles(dir) {
1991
2271
  }
1992
2272
  async function validateMdxContent() {
1993
2273
  assertConfigured();
2274
+ const config = await getConfig();
1994
2275
  const files = getMdxFiles(DOCS_DIR);
2276
+ const linkIndex = buildDocsLinkIndex(config, files);
1995
2277
  for (const file of files) {
2278
+ const relativePath = path.relative(DOCS_DIR, file);
1996
2279
  try {
1997
2280
  const content = fs.readFileSync(file, "utf-8");
1998
2281
  const match = content.match(/^---\n([\s\S]*?)\n---/);
@@ -2008,9 +2291,16 @@ async function validateMdxContent() {
2008
2291
  }
2009
2292
  }
2010
2293
  validateComponentUsage(content);
2011
- await compile(content, { jsx: true });
2294
+ await compile(content, {
2295
+ jsx: true,
2296
+ remarkPlugins: [
2297
+ createInternalLinkValidationPlugin({
2298
+ sourceFile: relativePath,
2299
+ linkIndex
2300
+ })
2301
+ ]
2302
+ });
2012
2303
  } catch (e) {
2013
- const relativePath = path.relative(DOCS_DIR, file);
2014
2304
  const location = e.line ? `:${e.line}:${e.column}` : "";
2015
2305
  const reason = e.reason || e.message;
2016
2306
  throw new Error(
@@ -2025,11 +2315,15 @@ export {
2025
2315
  DEFAULT_SHIKI_LIGHT_THEME,
2026
2316
  DEFAULT_THEME_COLOR_DARK,
2027
2317
  DEFAULT_THEME_COLOR_LIGHT,
2318
+ PUBLISHABLE_STATIC_ASSET_EXTENSIONS,
2028
2319
  SHIKI_BUNDLED_THEME_NAMES,
2029
2320
  configureDocsValidator,
2030
2321
  docsSchema,
2031
2322
  getConfig,
2032
2323
  isBundledShikiThemeName,
2324
+ isPublishableStaticAssetPath,
2033
2325
  loadOpenApiSpec,
2326
+ resolveDocsHref,
2327
+ resolveDocsPageHref,
2034
2328
  validateMdxContent
2035
2329
  };
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "radiant-docs-validator",
3
- "version": "0.1.0",
3
+ "version": "0.1.4",
4
4
  "description": "Shared validation for Radiant documentation repositories",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "build": "tsup src/index.ts src/frontmatter-schema.ts src/shiki-theme-config.ts --format esm --dts --clean",
8
+ "test": "npm run build && node --test test/*.test.mjs",
8
9
  "prepublishOnly": "npm run build",
9
10
  "release": "node scripts/release.js"
10
11
  },