radiant-docs-validator 0.1.0 → 0.1.3

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,26 @@ type Footer = {
163
163
  socials?: Partial<Record<SocialPlatform, string>>;
164
164
  links?: FooterLink[];
165
165
  };
166
+ type DocsPageHrefResolution = {
167
+ kind: "ignored";
168
+ } | {
169
+ kind: "invalid";
170
+ reason: "not-root-absolute" | "generated-route" | "empty-target";
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
+ };
166
183
  declare function loadOpenApiSpec(filePathOrUrl: string): Promise<any>;
167
184
  declare function getConfig(): Promise<DocsConfig>;
185
+ declare function resolveDocsPageHref(href: string): DocsPageHrefResolution;
168
186
  declare function validateMdxContent(): Promise<void>;
169
187
 
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 };
188
+ 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 DocsPageHrefResolution, 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, resolveDocsPageHref, validateMdxContent };
package/dist/index.js CHANGED
@@ -289,6 +289,14 @@ function validateFileExistence(filePath, currentPath) {
289
289
  );
290
290
  }
291
291
  }
292
+ function normalizeDocsFilePath(value) {
293
+ let normalized = value.replace(/\\/g, "/").trim();
294
+ if (!normalized) return "";
295
+ normalized = normalized.replace(/^\/+/, "").replace(/\/+$/, "");
296
+ normalized = normalized.replace(/\.(md|mdx)$/i, "");
297
+ const posixNormalized = path.posix.normalize(`/${normalized}`).replace(/^\/+/, "");
298
+ return posixNormalized === "." ? "" : posixNormalized;
299
+ }
292
300
  function normalizeDocsPagePath(value, currentPath, label = "Page path") {
293
301
  checkType(value, "string", currentPath, label);
294
302
  const trimmedPath = value.trim();
@@ -1975,6 +1983,195 @@ function validateComponentUsage(content) {
1975
1983
  );
1976
1984
  }
1977
1985
  }
1986
+ var EXTERNAL_PROTOCOL_REGEX = /^[a-zA-Z][a-zA-Z\d+\-.]*:/;
1987
+ var DOCS_PAGE_SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([".md", ".mdx"]);
1988
+ var DOCS_PAGE_ROUTE_EXTENSIONS = /* @__PURE__ */ new Set([".htm", ".html"]);
1989
+ function isIgnoredHref(pathname) {
1990
+ if (!pathname) return true;
1991
+ if (pathname.startsWith("#")) return true;
1992
+ if (pathname.startsWith("?")) return true;
1993
+ if (pathname.startsWith("//")) return true;
1994
+ return EXTERNAL_PROTOCOL_REGEX.test(pathname);
1995
+ }
1996
+ function getHrefExtension(pathname) {
1997
+ return path.posix.extname(pathname.replace(/\\/g, "/")).toLowerCase();
1998
+ }
1999
+ function isDocsPageLikeHref(pathname) {
2000
+ if (isIgnoredHref(pathname)) return false;
2001
+ const extension = getHrefExtension(pathname);
2002
+ if (!extension) return true;
2003
+ if (DOCS_PAGE_SOURCE_EXTENSIONS.has(extension)) return true;
2004
+ return DOCS_PAGE_ROUTE_EXTENSIONS.has(extension);
2005
+ }
2006
+ function resolveDocsPageHref(href) {
2007
+ const { pathname, suffix } = splitHrefPathAndSuffix(href);
2008
+ const baseHref = pathname.trim();
2009
+ if (!isDocsPageLikeHref(baseHref)) {
2010
+ return { kind: "ignored" };
2011
+ }
2012
+ const filePath = normalizeDocsFilePath(baseHref);
2013
+ const baseResult = {
2014
+ href,
2015
+ pathname: baseHref,
2016
+ suffix,
2017
+ filePath
2018
+ };
2019
+ if (!baseHref.startsWith("/")) {
2020
+ return {
2021
+ kind: "invalid",
2022
+ reason: "not-root-absolute",
2023
+ ...baseResult,
2024
+ suggestedHref: filePath ? `/${filePath}` : void 0
2025
+ };
2026
+ }
2027
+ const extension = getHrefExtension(baseHref);
2028
+ if (extension && !DOCS_PAGE_SOURCE_EXTENSIONS.has(extension)) {
2029
+ return {
2030
+ kind: "invalid",
2031
+ reason: "generated-route",
2032
+ ...baseResult
2033
+ };
2034
+ }
2035
+ if (!filePath) {
2036
+ return {
2037
+ kind: "invalid",
2038
+ reason: "empty-target",
2039
+ ...baseResult
2040
+ };
2041
+ }
2042
+ return {
2043
+ kind: "docs-page",
2044
+ ...baseResult
2045
+ };
2046
+ }
2047
+ function addRoutableDocsFilePath(routableFilePaths, filePath) {
2048
+ if (typeof filePath !== "string") return;
2049
+ const normalizedFilePath = normalizeDocsFilePath(filePath);
2050
+ if (normalizedFilePath) {
2051
+ routableFilePaths.add(normalizedFilePath);
2052
+ }
2053
+ }
2054
+ function collectRoutablePageItems(routableFilePaths, items) {
2055
+ if (!Array.isArray(items)) return;
2056
+ for (const item of items) {
2057
+ if (typeof item === "string") {
2058
+ addRoutableDocsFilePath(routableFilePaths, item);
2059
+ continue;
2060
+ }
2061
+ if (!item || typeof item !== "object") continue;
2062
+ if ("page" in item) {
2063
+ addRoutableDocsFilePath(routableFilePaths, item.page);
2064
+ continue;
2065
+ }
2066
+ if ("group" in item) {
2067
+ collectRoutablePageItems(
2068
+ routableFilePaths,
2069
+ item.pages
2070
+ );
2071
+ }
2072
+ }
2073
+ }
2074
+ function collectRoutableNavigationPages(routableFilePaths, config) {
2075
+ collectRoutablePageItems(
2076
+ routableFilePaths,
2077
+ config.navigation.pages
2078
+ );
2079
+ for (const item of config.navigation.menu?.items ?? []) {
2080
+ collectRoutablePageItems(
2081
+ routableFilePaths,
2082
+ item.submenu.pages
2083
+ );
2084
+ }
2085
+ }
2086
+ function buildDocsLinkIndex(config, files) {
2087
+ const allDocsFilePaths = /* @__PURE__ */ new Set();
2088
+ const routableDocsFilePaths = /* @__PURE__ */ new Set();
2089
+ for (const file of files) {
2090
+ const relativePath = path.relative(DOCS_DIR, file);
2091
+ const normalizedFilePath = normalizeDocsFilePath(relativePath);
2092
+ if (normalizedFilePath) {
2093
+ allDocsFilePaths.add(normalizedFilePath);
2094
+ }
2095
+ }
2096
+ collectRoutableNavigationPages(routableDocsFilePaths, config);
2097
+ addRoutableDocsFilePath(routableDocsFilePaths, config.home);
2098
+ for (const route of config.hiddenPageRoutes ?? []) {
2099
+ addRoutableDocsFilePath(routableDocsFilePaths, route.filePath);
2100
+ }
2101
+ return {
2102
+ allDocsFilePaths,
2103
+ routableDocsFilePaths
2104
+ };
2105
+ }
2106
+ function validateDocsRootAbsoluteHref(args) {
2107
+ const resolvedHref = resolveDocsPageHref(args.href);
2108
+ if (resolvedHref.kind === "ignored") return;
2109
+ if (resolvedHref.kind === "invalid") {
2110
+ if (resolvedHref.reason === "not-root-absolute") {
2111
+ const suggestion = resolvedHref.suggestedHref ? ` Use "${resolvedHref.suggestedHref}" instead.` : "";
2112
+ throw new Error(
2113
+ `Invalid internal link "${args.href}" in ${args.sourceFile}. Docs page links must be docs-root absolute paths like "/guides/quickstart".${suggestion}`
2114
+ );
2115
+ }
2116
+ if (resolvedHref.reason === "generated-route") {
2117
+ throw new Error(
2118
+ `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".`
2119
+ );
2120
+ }
2121
+ throw new Error(
2122
+ `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".`
2123
+ );
2124
+ }
2125
+ if (args.linkIndex.routableDocsFilePaths.has(resolvedHref.filePath)) {
2126
+ return;
2127
+ }
2128
+ if (args.linkIndex.allDocsFilePaths.has(resolvedHref.filePath)) {
2129
+ throw new Error(
2130
+ `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.`
2131
+ );
2132
+ }
2133
+ throw new Error(
2134
+ `Invalid internal link "${args.href}" in ${args.sourceFile}. No matching docs page file was found. Expected "${resolvedHref.filePath}.mdx" under the docs root.`
2135
+ );
2136
+ }
2137
+ function visitMdxTree(node, visitor) {
2138
+ if (!node || typeof node !== "object") return;
2139
+ const typedNode = node;
2140
+ visitor(node);
2141
+ if (!Array.isArray(typedNode.children)) return;
2142
+ for (const child of typedNode.children) {
2143
+ visitMdxTree(child, visitor);
2144
+ }
2145
+ }
2146
+ function createInternalLinkValidationPlugin(args) {
2147
+ return () => (tree) => {
2148
+ visitMdxTree(tree, (node) => {
2149
+ if ((node.type === "link" || node.type === "definition") && typeof node.url === "string") {
2150
+ validateDocsRootAbsoluteHref({
2151
+ href: node.url,
2152
+ sourceFile: args.sourceFile,
2153
+ linkIndex: args.linkIndex
2154
+ });
2155
+ }
2156
+ if (node.type !== "mdxJsxFlowElement" && node.type !== "mdxJsxTextElement") {
2157
+ return;
2158
+ }
2159
+ const element = node;
2160
+ if (!Array.isArray(element.attributes)) return;
2161
+ for (const attribute of element.attributes) {
2162
+ const hrefAttribute = attribute;
2163
+ if (hrefAttribute.type !== "mdxJsxAttribute") continue;
2164
+ if (hrefAttribute.name !== "href") continue;
2165
+ if (typeof hrefAttribute.value !== "string") continue;
2166
+ validateDocsRootAbsoluteHref({
2167
+ href: hrefAttribute.value,
2168
+ sourceFile: args.sourceFile,
2169
+ linkIndex: args.linkIndex
2170
+ });
2171
+ }
2172
+ });
2173
+ };
2174
+ }
1978
2175
  function getMdxFiles(dir) {
1979
2176
  let results = [];
1980
2177
  const list = fs.readdirSync(dir);
@@ -1983,7 +2180,7 @@ function getMdxFiles(dir) {
1983
2180
  const stat = fs.statSync(file);
1984
2181
  if (stat && stat.isDirectory()) {
1985
2182
  results = results.concat(getMdxFiles(file));
1986
- } else if (file.endsWith(".mdx")) {
2183
+ } else if (/\.(md|mdx)$/i.test(file)) {
1987
2184
  results.push(file);
1988
2185
  }
1989
2186
  });
@@ -1991,8 +2188,11 @@ function getMdxFiles(dir) {
1991
2188
  }
1992
2189
  async function validateMdxContent() {
1993
2190
  assertConfigured();
2191
+ const config = await getConfig();
1994
2192
  const files = getMdxFiles(DOCS_DIR);
2193
+ const linkIndex = buildDocsLinkIndex(config, files);
1995
2194
  for (const file of files) {
2195
+ const relativePath = path.relative(DOCS_DIR, file);
1996
2196
  try {
1997
2197
  const content = fs.readFileSync(file, "utf-8");
1998
2198
  const match = content.match(/^---\n([\s\S]*?)\n---/);
@@ -2008,9 +2208,16 @@ async function validateMdxContent() {
2008
2208
  }
2009
2209
  }
2010
2210
  validateComponentUsage(content);
2011
- await compile(content, { jsx: true });
2211
+ await compile(content, {
2212
+ jsx: true,
2213
+ remarkPlugins: [
2214
+ createInternalLinkValidationPlugin({
2215
+ sourceFile: relativePath,
2216
+ linkIndex
2217
+ })
2218
+ ]
2219
+ });
2012
2220
  } catch (e) {
2013
- const relativePath = path.relative(DOCS_DIR, file);
2014
2221
  const location = e.line ? `:${e.line}:${e.column}` : "";
2015
2222
  const reason = e.reason || e.message;
2016
2223
  throw new Error(
@@ -2031,5 +2238,6 @@ export {
2031
2238
  getConfig,
2032
2239
  isBundledShikiThemeName,
2033
2240
  loadOpenApiSpec,
2241
+ resolveDocsPageHref,
2034
2242
  validateMdxContent
2035
2243
  };
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.3",
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
  },