@marlinjai/clearify 1.10.0 → 1.11.0

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/README.md CHANGED
@@ -1,3 +1,12 @@
1
+ ---
2
+ title: Clearify
3
+ summary: README for Clearify, an open-source documentation site generator that turns markdown into beautiful docs with zero config, MDX support, Mermaid diagrams, full-text search, OpenAPI rendering, and SSG with SEO.
4
+ category: documentation
5
+ tags: [clearify, documentation, mdx, vite, ssg]
6
+ status: active
7
+ date: 2026-02-07
8
+ ---
9
+
1
10
  # Clearify
2
11
 
3
12
  An open-source documentation site generator. Turn markdown into beautiful docs.
@@ -171,6 +171,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
171
171
  } else {
172
172
  console.log(" CHANGELOG.md already exists, skipping");
173
173
  }
174
+ const roadmapPath = resolve(cwd, "ROADMAP.md");
175
+ if (!existsSync(roadmapPath)) {
176
+ writeFileSync(
177
+ roadmapPath,
178
+ `# Roadmap
179
+
180
+ ## Planned
181
+
182
+ <!-- Decided features, ready to be worked on -->
183
+
184
+ ## In Progress
185
+
186
+ <!-- Currently being implemented -->
187
+
188
+ ## Completed
189
+
190
+ <!-- Done \u2014 move to CHANGELOG.md on release -->
191
+ `
192
+ );
193
+ console.log(" Created ROADMAP.md");
194
+ } else {
195
+ console.log(" ROADMAP.md already exists, skipping");
196
+ }
174
197
  if (!existsSync(configPath)) {
175
198
  if (options.noInternal) {
176
199
  writeFileSync(
@@ -8,7 +8,7 @@ import {
8
8
  } from "./chunk-SCZZB7OE.js";
9
9
  import {
10
10
  clearifyPlugin
11
- } from "./chunk-2RAA6477.js";
11
+ } from "./chunk-PFX7LHRZ.js";
12
12
  import {
13
13
  loadUserConfig,
14
14
  resolveConfig,
@@ -16,7 +16,7 @@ import {
16
16
  } from "./chunk-PVTUVUBK.js";
17
17
  import {
18
18
  buildSectionData
19
- } from "./chunk-QNC5QRIO.js";
19
+ } from "./chunk-QTGT76RY.js";
20
20
 
21
21
  // src/node/build.ts
22
22
  import { build as viteBuild } from "vite";
@@ -27,10 +27,11 @@ import remarkFrontmatter from "remark-frontmatter";
27
27
  import remarkMdxFrontmatter from "remark-mdx-frontmatter";
28
28
  import rehypeShiki from "@shikijs/rehype";
29
29
  import tailwindcss from "@tailwindcss/vite";
30
- import { resolve } from "path";
30
+ import { resolve, dirname } from "path";
31
31
  import { writeFileSync, readFileSync, existsSync, mkdirSync, rmSync, copyFileSync } from "fs";
32
32
  import { basename } from "path";
33
33
  import { fileURLToPath } from "url";
34
+ import { createRequire } from "module";
34
35
  function findPackageRoot() {
35
36
  let dir = fileURLToPath(new URL(".", import.meta.url));
36
37
  for (let i = 0; i < 10; i++) {
@@ -45,6 +46,18 @@ function resolveClientPath(...segments) {
45
46
  const packageRoot = findPackageRoot();
46
47
  return resolve(packageRoot, "src", "client", ...segments);
47
48
  }
49
+ function dedupeResolve() {
50
+ const require2 = createRequire(import.meta.url);
51
+ const dedupePkgs = ["@mdx-js/react", "react", "react-dom", "react-router-dom"];
52
+ const alias = {};
53
+ for (const pkg of dedupePkgs) {
54
+ try {
55
+ alias[pkg] = dirname(require2.resolve(`${pkg}/package.json`));
56
+ } catch {
57
+ }
58
+ }
59
+ return { alias, dedupe: dedupePkgs };
60
+ }
48
61
  function escapeHtml(str) {
49
62
  return str.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
50
63
  }
@@ -142,9 +155,12 @@ async function buildSite() {
142
155
  const ssrOutDir = resolve(outDir, ".ssr");
143
156
  const allSections = await resolveSections(config, userRoot);
144
157
  const productionSections = allSections.filter((s) => !s.draft);
145
- const changelogPath = resolve(userRoot, "CHANGELOG.md");
158
+ const rootFiles = {
159
+ changelogPath: resolve(userRoot, "CHANGELOG.md"),
160
+ roadmapPath: resolve(userRoot, "ROADMAP.md")
161
+ };
146
162
  const sectionDataList = productionSections.map(
147
- (section) => buildSectionData(section, changelogPath, config.navigation)
163
+ (section) => buildSectionData(section, rootFiles, config.navigation)
148
164
  );
149
165
  const allRoutes = sectionDataList.flatMap((sd) => sd.routes);
150
166
  if (config.openapi?.spec && config.openapi.generatePages !== false) {
@@ -207,11 +223,13 @@ async function buildSite() {
207
223
  mermaidSvgs,
208
224
  mermaidStrategy
209
225
  };
226
+ const { alias: dedupeAlias, dedupe } = dedupeResolve();
210
227
  const clientConfig = {
211
228
  root,
212
229
  plugins: getSharedPlugins(userRoot, sharedPluginOptions),
213
230
  resolve: {
214
- alias: { "@clearify": resolve(root, "..") }
231
+ alias: { "@clearify": resolve(root, ".."), ...dedupeAlias },
232
+ dedupe
215
233
  },
216
234
  build: {
217
235
  outDir,
@@ -225,7 +243,8 @@ async function buildSite() {
225
243
  root,
226
244
  plugins: getSharedPlugins(userRoot, sharedPluginOptions),
227
245
  resolve: {
228
- alias: { "@clearify": resolve(root, "..") }
246
+ alias: { "@clearify": resolve(root, "..") },
247
+ dedupe
229
248
  },
230
249
  build: {
231
250
  outDir: ssrOutDir,
@@ -0,0 +1,264 @@
1
+ import {
2
+ loadUserConfig,
3
+ resolveConfig,
4
+ resolveSections
5
+ } from "./chunk-PVTUVUBK.js";
6
+ import {
7
+ scanDocs
8
+ } from "./chunk-QTGT76RY.js";
9
+
10
+ // src/node/check.ts
11
+ import { resolve, relative } from "path";
12
+ import { readFileSync, existsSync } from "fs";
13
+ import matter from "gray-matter";
14
+ var VALID_CATEGORIES = [
15
+ "documentation",
16
+ "internal",
17
+ "plan",
18
+ "research",
19
+ "decision",
20
+ "roadmap",
21
+ "changelog"
22
+ ];
23
+ var VALID_STATUSES = ["active", "superseded", "archived"];
24
+ function isStrictPath(filePath) {
25
+ const normalized = filePath.replace(/\\/g, "/");
26
+ return /\/docs\/plans(\/|$)/.test(normalized) || /\/docs\/internal(\/|$)/.test(normalized);
27
+ }
28
+ function validateFrontmatter(doc, userRoot) {
29
+ const issues = [];
30
+ const rel = relative(userRoot, doc.filePath);
31
+ const strict = isStrictPath(doc.filePath);
32
+ let rawFm = {};
33
+ try {
34
+ const content = readFileSync(doc.filePath, "utf-8");
35
+ rawFm = matter(content).data;
36
+ } catch {
37
+ rawFm = {};
38
+ }
39
+ if (strict) {
40
+ if (!rawFm.title) {
41
+ issues.push({ file: rel, field: "title", message: "missing required field", severity: "error" });
42
+ }
43
+ if (!rawFm.summary) {
44
+ issues.push({ file: rel, field: "summary", message: "missing required field", severity: "error" });
45
+ }
46
+ if (!rawFm.category) {
47
+ issues.push({ file: rel, field: "category", message: "missing required field", severity: "error" });
48
+ }
49
+ } else {
50
+ if (!rawFm.title) {
51
+ issues.push({ file: rel, field: "title", message: "missing required field", severity: "error" });
52
+ }
53
+ if (!rawFm.summary) {
54
+ issues.push({ file: rel, field: "summary", message: "recommended field missing", severity: "warning" });
55
+ }
56
+ if (!rawFm.category) {
57
+ issues.push({ file: rel, field: "category", message: "recommended field missing", severity: "warning" });
58
+ }
59
+ }
60
+ if (rawFm.category !== void 0) {
61
+ if (!VALID_CATEGORIES.includes(rawFm.category)) {
62
+ issues.push({
63
+ file: rel,
64
+ field: "category",
65
+ message: `invalid value "${rawFm.category}" (allowed: ${VALID_CATEGORIES.join(", ")})`,
66
+ severity: "error"
67
+ });
68
+ }
69
+ }
70
+ if (rawFm.status !== void 0) {
71
+ if (!VALID_STATUSES.includes(rawFm.status)) {
72
+ issues.push({
73
+ file: rel,
74
+ field: "status",
75
+ message: `invalid value "${rawFm.status}" (allowed: ${VALID_STATUSES.join(", ")})`,
76
+ severity: "error"
77
+ });
78
+ }
79
+ }
80
+ if (rawFm.tags !== void 0 && !Array.isArray(rawFm.tags)) {
81
+ issues.push({ file: rel, field: "tags", message: "must be an array", severity: "error" });
82
+ }
83
+ if (rawFm.projects !== void 0 && !Array.isArray(rawFm.projects)) {
84
+ issues.push({ file: rel, field: "projects", message: "must be an array", severity: "error" });
85
+ }
86
+ return issues;
87
+ }
88
+ async function checkFrontmatter() {
89
+ const userRoot = process.cwd();
90
+ const userConfig = await loadUserConfig(userRoot);
91
+ const config = resolveConfig(userConfig, userRoot);
92
+ const sections = await resolveSections(config, userRoot);
93
+ const allDocs = [];
94
+ for (const section of sections) {
95
+ const docs = scanDocs(section.docsDir, section.exclude, section.basePath);
96
+ allDocs.push(...docs);
97
+ }
98
+ const allIssues = [];
99
+ for (const doc of allDocs) {
100
+ const issues = validateFrontmatter(doc, userRoot);
101
+ allIssues.push(...issues);
102
+ }
103
+ const errors = allIssues.filter((i) => i.severity === "error");
104
+ const warnings = allIssues.filter((i) => i.severity === "warning");
105
+ if (allIssues.length === 0) {
106
+ console.log("\n Frontmatter: all files valid.\n");
107
+ return { errors: 0, warnings: 0 };
108
+ }
109
+ console.log(`
110
+ Frontmatter validation: ${errors.length} error(s), ${warnings.length} warning(s)
111
+ `);
112
+ const fileCol = Math.max(4, ...allIssues.map((i) => i.file.length));
113
+ const fieldCol = Math.max(5, ...allIssues.map((i) => i.field.length));
114
+ const msgCol = Math.max(7, ...allIssues.map((i) => i.message.length));
115
+ const sevCol = 7;
116
+ const header = ` ${"File".padEnd(fileCol)} ${"Field".padEnd(fieldCol)} ${"Severity".padEnd(sevCol)} ${"Message".padEnd(msgCol)}`;
117
+ const separator = ` ${"\u2500".repeat(fileCol)} ${"\u2500".repeat(fieldCol)} ${"\u2500".repeat(sevCol)} ${"\u2500".repeat(msgCol)}`;
118
+ console.log(header);
119
+ console.log(separator);
120
+ for (const issue of allIssues) {
121
+ const sev = issue.severity === "error" ? "ERROR " : "warning";
122
+ console.log(` ${issue.file.padEnd(fileCol)} ${issue.field.padEnd(fieldCol)} ${sev} ${issue.message}`);
123
+ }
124
+ console.log();
125
+ return { errors: errors.length, warnings: warnings.length };
126
+ }
127
+ async function checkLinks(options = {}) {
128
+ const fmResult = await checkFrontmatter();
129
+ if (options.frontmatterOnly) {
130
+ if (fmResult.errors > 0) {
131
+ process.exitCode = 1;
132
+ }
133
+ return;
134
+ }
135
+ const userRoot = process.cwd();
136
+ const userConfig = await loadUserConfig(userRoot);
137
+ const config = resolveConfig(userConfig, userRoot);
138
+ const sections = await resolveSections(config, userRoot);
139
+ const validRoutes = /* @__PURE__ */ new Set();
140
+ const allDocs = [];
141
+ for (const section of sections) {
142
+ const docs = scanDocs(section.docsDir, section.exclude, section.basePath);
143
+ for (const doc of docs) {
144
+ validRoutes.add(doc.routePath);
145
+ allDocs.push(doc);
146
+ }
147
+ }
148
+ validRoutes.add("/");
149
+ const changelogPath = resolve(userRoot, "CHANGELOG.md");
150
+ if (existsSync(changelogPath)) {
151
+ validRoutes.add("/changelog");
152
+ }
153
+ const roadmapPath = resolve(userRoot, "ROADMAP.md");
154
+ if (existsSync(roadmapPath)) {
155
+ validRoutes.add("/roadmap");
156
+ }
157
+ const expandedRoutes = new Set(validRoutes);
158
+ for (const route of validRoutes) {
159
+ expandedRoutes.add(route.replace(/\/$/, ""));
160
+ if (route !== "/") expandedRoutes.add(route + "/");
161
+ }
162
+ const mdLinkRegex = /\[(?:[^\]]*)\]\(([^)]+)\)/g;
163
+ const hrefRegex = /href=["']([^"']+)["']/g;
164
+ const brokenLinks = [];
165
+ for (const doc of allDocs) {
166
+ if (!existsSync(doc.filePath)) continue;
167
+ const content = readFileSync(doc.filePath, "utf-8");
168
+ const lines = content.split("\n");
169
+ for (let i = 0; i < lines.length; i++) {
170
+ const line = lines[i];
171
+ const lineNum = i + 1;
172
+ if (line.trimStart().startsWith("```")) continue;
173
+ for (const regex of [mdLinkRegex, hrefRegex]) {
174
+ regex.lastIndex = 0;
175
+ let match;
176
+ while ((match = regex.exec(line)) !== null) {
177
+ const rawLink = match[1].split(/[\s"'#]/)[0];
178
+ if (!rawLink || rawLink.startsWith("http://") || rawLink.startsWith("https://") || rawLink.startsWith("#") || rawLink.startsWith("mailto:") || rawLink.startsWith("tel:") || rawLink.startsWith("//") || rawLink.startsWith("{") || rawLink.startsWith("data:")) {
179
+ continue;
180
+ }
181
+ if (/\.(png|jpe?g|gif|svg|webp|ico|pdf|zip|tar|gz)$/i.test(rawLink)) {
182
+ continue;
183
+ }
184
+ let normalized = rawLink;
185
+ normalized = normalized.replace(/\.(mdx?|html?)$/, "");
186
+ if (!normalized.startsWith("/")) {
187
+ const docDir = doc.routePath.substring(0, doc.routePath.lastIndexOf("/")) || "/";
188
+ normalized = docDir === "/" ? "/" + normalized : docDir + "/" + normalized;
189
+ }
190
+ const check = normalized === "/" ? "/" : normalized.replace(/\/$/, "");
191
+ const checkNoIndex = check.replace(/\/index$/, "") || "/";
192
+ if (!expandedRoutes.has(check) && !expandedRoutes.has(checkNoIndex)) {
193
+ const suggestion = findClosest(check, validRoutes);
194
+ brokenLinks.push({
195
+ file: doc.filePath,
196
+ line: lineNum,
197
+ link: rawLink,
198
+ ...suggestion ? { suggestion } : {}
199
+ });
200
+ }
201
+ }
202
+ }
203
+ }
204
+ }
205
+ if (brokenLinks.length === 0) {
206
+ console.log("\n No broken links found.\n");
207
+ } else {
208
+ console.log(`
209
+ Found ${brokenLinks.length} broken link${brokenLinks.length === 1 ? "" : "s"}:
210
+ `);
211
+ const byFile = /* @__PURE__ */ new Map();
212
+ for (const bl of brokenLinks) {
213
+ const rel = bl.file.replace(userRoot + "/", "");
214
+ if (!byFile.has(rel)) byFile.set(rel, []);
215
+ byFile.get(rel).push(bl);
216
+ }
217
+ for (const [file, links] of byFile) {
218
+ console.log(` ${file}`);
219
+ for (const link of links) {
220
+ const suggestion = link.suggestion ? ` (did you mean ${link.suggestion}?)` : "";
221
+ console.log(` line ${link.line}: ${link.link}${suggestion}`);
222
+ }
223
+ console.log();
224
+ }
225
+ }
226
+ if (brokenLinks.length > 0 || fmResult.errors > 0) {
227
+ process.exitCode = 1;
228
+ }
229
+ }
230
+ function findClosest(target, routes) {
231
+ let best;
232
+ let bestScore = Infinity;
233
+ const targetSegments = target.split("/").filter(Boolean);
234
+ for (const route of routes) {
235
+ const routeSegments = route.split("/").filter(Boolean);
236
+ const dist = levenshtein(target, route);
237
+ const common = targetSegments.filter((s) => routeSegments.includes(s)).length;
238
+ const score = dist - common * 2;
239
+ if (score < bestScore) {
240
+ bestScore = score;
241
+ best = route;
242
+ }
243
+ }
244
+ if (best && bestScore < target.length * 0.6) return best;
245
+ return void 0;
246
+ }
247
+ function levenshtein(a, b) {
248
+ const m = a.length;
249
+ const n = b.length;
250
+ const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
251
+ for (let i = 0; i <= m; i++) dp[i][0] = i;
252
+ for (let j = 0; j <= n; j++) dp[0][j] = j;
253
+ for (let i = 1; i <= m; i++) {
254
+ for (let j = 1; j <= n; j++) {
255
+ dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
256
+ }
257
+ }
258
+ return dp[m][n];
259
+ }
260
+
261
+ export {
262
+ checkFrontmatter,
263
+ checkLinks
264
+ };
@@ -9,7 +9,7 @@ import {
9
9
  } from "./chunk-PVTUVUBK.js";
10
10
  import {
11
11
  buildSectionData
12
- } from "./chunk-QNC5QRIO.js";
12
+ } from "./chunk-QTGT76RY.js";
13
13
 
14
14
  // src/vite-plugin/index.ts
15
15
  import { resolve } from "path";
@@ -49,10 +49,16 @@ function clearifyPlugin(options = {}) {
49
49
  let allSectionNavigations;
50
50
  let allSearchEntries;
51
51
  let mermaidSvgData = options.mermaidSvgs ?? {};
52
+ function getRootFiles() {
53
+ return {
54
+ changelogPath: resolve(userRoot, "CHANGELOG.md"),
55
+ roadmapPath: resolve(userRoot, "ROADMAP.md")
56
+ };
57
+ }
52
58
  async function refreshDocs() {
53
- const changelogPath = resolve(userRoot, "CHANGELOG.md");
59
+ const rootFiles = getRootFiles();
54
60
  sectionDataList = visibleSections.map(
55
- (section) => buildSectionData(section, changelogPath, config.navigation)
61
+ (section) => buildSectionData(section, rootFiles, config.navigation)
56
62
  );
57
63
  allRoutes = sectionDataList.flatMap((sd) => sd.routes);
58
64
  allSectionNavigations = sectionDataList.map((sd) => sd.navigation);
@@ -266,9 +272,11 @@ ${routeEntries.join(",\n")}
266
272
  for (const section of visibleSections) {
267
273
  server.watcher.add(section.docsDir);
268
274
  }
269
- const changelogPath = resolve(userRoot, "CHANGELOG.md");
270
- if (existsSync(changelogPath)) {
271
- server.watcher.add(changelogPath);
275
+ const rootFiles = getRootFiles();
276
+ for (const rootFile of [rootFiles.changelogPath, rootFiles.roadmapPath]) {
277
+ if (rootFile && existsSync(rootFile)) {
278
+ server.watcher.add(rootFile);
279
+ }
272
280
  }
273
281
  if (config.openapi?.spec) {
274
282
  const openapiPath = resolve(userRoot, config.openapi.spec);
@@ -385,9 +393,10 @@ ${routeEntries.join(",\n")}
385
393
  });
386
394
  }
387
395
  server.watcher.on("all", (event, path) => {
388
- const isChangelog = path === changelogPath;
396
+ const isChangelog = path === rootFiles.changelogPath;
397
+ const isRoadmap = path === rootFiles.roadmapPath;
389
398
  const isInSection = visibleSections.some((s) => path.startsWith(s.docsDir));
390
- if (!isChangelog && !isInSection) return;
399
+ if (!isChangelog && !isRoadmap && !isInSection) return;
391
400
  if (!path.endsWith(".md") && !path.endsWith(".mdx")) return;
392
401
  if (event === "add" || event === "unlink" || event === "change") {
393
402
  refreshDocs().then(() => {
@@ -40,7 +40,13 @@ function scanDocs(docsDir, exclude = [], basePath = "/") {
40
40
  title: data.title ?? toTitleCase(basename(filePath, extname(filePath))),
41
41
  description: data.description,
42
42
  icon: data.icon,
43
- order: data.order
43
+ order: data.order,
44
+ summary: data.summary,
45
+ category: data.category,
46
+ tags: data.tags,
47
+ projects: data.projects,
48
+ status: data.status,
49
+ date: data.date
44
50
  }
45
51
  };
46
52
  });
@@ -127,20 +133,35 @@ function buildRoutes(docs, sectionId) {
127
133
  ...sectionId ? { sectionId } : {}
128
134
  }));
129
135
  }
130
- function buildSectionData(section, changelogPath, customNavigation) {
136
+ function buildSectionData(section, rootFiles, customNavigation) {
131
137
  const docs = scanDocs(section.docsDir, section.exclude, section.basePath);
132
- if (section.basePath === "/" && changelogPath && existsSync(changelogPath)) {
133
- const content = readFileSync(changelogPath, "utf-8");
134
- const { data } = matter(content);
135
- docs.push({
136
- filePath: changelogPath,
137
- routePath: "/changelog",
138
- frontmatter: {
139
- title: data.title ?? "Changelog",
140
- description: data.description ?? "Release history",
141
- order: 9999
142
- }
143
- });
138
+ if (section.basePath === "/") {
139
+ if (rootFiles?.changelogPath && existsSync(rootFiles.changelogPath)) {
140
+ const content = readFileSync(rootFiles.changelogPath, "utf-8");
141
+ const { data } = matter(content);
142
+ docs.push({
143
+ filePath: rootFiles.changelogPath,
144
+ routePath: "/changelog",
145
+ frontmatter: {
146
+ title: data.title ?? "Changelog",
147
+ description: data.description ?? "Release history",
148
+ order: 9999
149
+ }
150
+ });
151
+ }
152
+ if (rootFiles?.roadmapPath && existsSync(rootFiles.roadmapPath)) {
153
+ const content = readFileSync(rootFiles.roadmapPath, "utf-8");
154
+ const { data } = matter(content);
155
+ docs.push({
156
+ filePath: rootFiles.roadmapPath,
157
+ routePath: "/roadmap",
158
+ frontmatter: {
159
+ title: data.title ?? "Roadmap",
160
+ description: data.description ?? "Planned and upcoming features",
161
+ order: 9998
162
+ }
163
+ });
164
+ }
144
165
  }
145
166
  const nav = section.basePath === "/" && customNavigation ? customNavigation : buildNavigation(docs, section.basePath);
146
167
  const navigation = {
@@ -1,5 +1,21 @@
1
1
  // src/cli/index.ts
2
2
  import cac from "cac";
3
+ import { dirname, join } from "path";
4
+ import { fileURLToPath } from "url";
5
+ import { readFileSync, existsSync } from "fs";
6
+ function findPackageVersion() {
7
+ let dir = dirname(fileURLToPath(import.meta.url));
8
+ for (let i = 0; i < 5; i++) {
9
+ const pkgPath = join(dir, "package.json");
10
+ if (existsSync(pkgPath)) {
11
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
12
+ if (pkg.name === "clearify" || pkg.name === "@marlinjai/clearify") return pkg.version;
13
+ }
14
+ dir = dirname(dir);
15
+ }
16
+ return "0.0.0";
17
+ }
18
+ var version = findPackageVersion();
3
19
  var cli = cac("clearify");
4
20
  cli.command("dev", "Start development server").option("--port <port>", "Port to listen on").option("--host", "Expose to network").action(async (options) => {
5
21
  const { createServer } = await import("../node/index.js");
@@ -22,9 +38,9 @@ cli.command("init", "Scaffold a docs folder").option("--no-internal", "Skip crea
22
38
  const { init } = await import("../node/index.js");
23
39
  await init({ noInternal: options.noInternal, noClaudeRules: options.noClaudeRules });
24
40
  });
25
- cli.command("check", "Check for broken internal links").action(async () => {
41
+ cli.command("check", "Check for broken internal links and frontmatter issues").option("--frontmatter", "Only run frontmatter validation (skip broken link detection)").action(async (options) => {
26
42
  const { checkLinks } = await import("../node/check.js");
27
- await checkLinks();
43
+ await checkLinks({ frontmatterOnly: options.frontmatter });
28
44
  });
29
45
  cli.command("openapi:generate", "Generate OpenAPI spec from NestJS app").option("--module <path>", "Path to NestJS app module", { default: "./src/app.module.ts" }).option("--output <path>", "Output path for spec file", { default: "./docs/openapi.json" }).option("--title <title>", "API documentation title").option("--description <desc>", "API documentation description").option("--version <version>", "API version").action(async (options) => {
30
46
  const { generateSpec } = await import("../presets/nestjs.js");
@@ -37,5 +53,5 @@ cli.command("openapi:generate", "Generate OpenAPI spec from NestJS app").option(
37
53
  });
38
54
  });
39
55
  cli.help();
40
- cli.version("1.8.0");
56
+ cli.version(version);
41
57
  cli.parse();
@@ -1,2 +1,2 @@
1
1
  import '../types/index.js';
2
- export { D as DocFile, S as SectionData, b as buildNavigation, a as buildRoutes, c as buildSectionData, s as scanDocs } from './search.js';
2
+ export { D as DocFile, R as RootFileOptions, S as SectionData, b as buildNavigation, a as buildRoutes, c as buildSectionData, s as scanDocs } from './search.js';
@@ -3,7 +3,7 @@ import {
3
3
  buildRoutes,
4
4
  buildSectionData,
5
5
  scanDocs
6
- } from "../chunk-QNC5QRIO.js";
6
+ } from "../chunk-QTGT76RY.js";
7
7
  import "../chunk-5TD7NQIW.js";
8
8
  export {
9
9
  buildNavigation,
@@ -26,6 +26,10 @@ interface SectionData {
26
26
  routes: RouteEntry[];
27
27
  searchEntries: SearchEntry[];
28
28
  }
29
- declare function buildSectionData(section: ResolvedSection, changelogPath?: string, customNavigation?: NavigationItem[] | null): SectionData;
29
+ interface RootFileOptions {
30
+ changelogPath?: string;
31
+ roadmapPath?: string;
32
+ }
33
+ declare function buildSectionData(section: ResolvedSection, rootFiles?: RootFileOptions, customNavigation?: NavigationItem[] | null): SectionData;
30
34
 
31
- export { type DocFile as D, type SectionData as S, type SearchEntry, buildRoutes as a, buildNavigation as b, buildSearchIndex, buildSectionData as c, scanDocs as s };
35
+ export { type DocFile as D, type RootFileOptions as R, type SectionData as S, type SearchEntry, buildRoutes as a, buildNavigation as b, buildSearchIndex, buildSectionData as c, scanDocs as s };
@@ -1,14 +1,14 @@
1
1
  import {
2
2
  buildSite
3
- } from "../chunk-ZNURJXMI.js";
3
+ } from "../chunk-JU53JE4P.js";
4
4
  import "../chunk-PRTER35L.js";
5
5
  import "../chunk-SCZZB7OE.js";
6
- import "../chunk-2RAA6477.js";
6
+ import "../chunk-PFX7LHRZ.js";
7
7
  import "../chunk-B2Q23JW3.js";
8
8
  import "../chunk-PVTUVUBK.js";
9
9
  import "../chunk-AJBCHKBP.js";
10
10
  import "../chunk-V7LLYIRO.js";
11
- import "../chunk-QNC5QRIO.js";
11
+ import "../chunk-QTGT76RY.js";
12
12
  import "../chunk-5TD7NQIW.js";
13
13
  export {
14
14
  buildSite
@@ -1,3 +1,10 @@
1
- declare function checkLinks(): Promise<void>;
1
+ interface CheckOptions {
2
+ frontmatterOnly?: boolean;
3
+ }
4
+ declare function checkFrontmatter(): Promise<{
5
+ errors: number;
6
+ warnings: number;
7
+ }>;
8
+ declare function checkLinks(options?: CheckOptions): Promise<void>;
2
9
 
3
- export { checkLinks };
10
+ export { type CheckOptions, checkFrontmatter, checkLinks };
@@ -1,11 +1,13 @@
1
1
  import {
2
+ checkFrontmatter,
2
3
  checkLinks
3
- } from "../chunk-ELRDUSZS.js";
4
+ } from "../chunk-NGWAMBXS.js";
4
5
  import "../chunk-PVTUVUBK.js";
5
6
  import "../chunk-AJBCHKBP.js";
6
7
  import "../chunk-V7LLYIRO.js";
7
- import "../chunk-QNC5QRIO.js";
8
+ import "../chunk-QTGT76RY.js";
8
9
  import "../chunk-5TD7NQIW.js";
9
10
  export {
11
+ checkFrontmatter,
10
12
  checkLinks
11
13
  };
@@ -1,7 +1,7 @@
1
1
  import * as vite from 'vite';
2
2
  export { buildSite as build } from './build.js';
3
3
  export { init } from './init.js';
4
- export { checkLinks } from './check.js';
4
+ export { checkFrontmatter, checkLinks } from './check.js';
5
5
 
6
6
  declare function createServer(options?: {
7
7
  port?: number;
@@ -1,27 +1,28 @@
1
1
  import {
2
2
  buildSite
3
- } from "../chunk-ZNURJXMI.js";
3
+ } from "../chunk-JU53JE4P.js";
4
4
  import {
5
5
  remarkMermaidToComponent
6
6
  } from "../chunk-PRTER35L.js";
7
7
  import "../chunk-SCZZB7OE.js";
8
8
  import {
9
9
  clearifyPlugin
10
- } from "../chunk-2RAA6477.js";
10
+ } from "../chunk-PFX7LHRZ.js";
11
11
  import "../chunk-B2Q23JW3.js";
12
12
  import {
13
13
  init
14
- } from "../chunk-QIAMD2Q6.js";
14
+ } from "../chunk-3CW4MOO4.js";
15
15
  import {
16
+ checkFrontmatter,
16
17
  checkLinks
17
- } from "../chunk-ELRDUSZS.js";
18
+ } from "../chunk-NGWAMBXS.js";
18
19
  import {
19
20
  loadUserConfig,
20
21
  resolveConfig
21
22
  } from "../chunk-PVTUVUBK.js";
22
23
  import "../chunk-AJBCHKBP.js";
23
24
  import "../chunk-V7LLYIRO.js";
24
- import "../chunk-QNC5QRIO.js";
25
+ import "../chunk-QTGT76RY.js";
25
26
  import "../chunk-5TD7NQIW.js";
26
27
 
27
28
  // src/node/index.ts
@@ -32,8 +33,9 @@ import remarkGfm from "remark-gfm";
32
33
  import remarkFrontmatter from "remark-frontmatter";
33
34
  import remarkMdxFrontmatter from "remark-mdx-frontmatter";
34
35
  import rehypeShiki from "@shikijs/rehype";
35
- import { resolve } from "path";
36
+ import { resolve, dirname } from "path";
36
37
  import { fileURLToPath } from "url";
38
+ import { createRequire } from "module";
37
39
  import tailwindcss from "@tailwindcss/vite";
38
40
  import { existsSync } from "fs";
39
41
  function findPackageRoot() {
@@ -50,9 +52,22 @@ function resolveClientPath(...segments) {
50
52
  const packageRoot = findPackageRoot();
51
53
  return resolve(packageRoot, "src", "client", ...segments);
52
54
  }
55
+ function dedupeResolve() {
56
+ const require2 = createRequire(import.meta.url);
57
+ const dedupePkgs = ["@mdx-js/react", "react", "react-dom", "react-router-dom"];
58
+ const alias = {};
59
+ for (const pkg of dedupePkgs) {
60
+ try {
61
+ alias[pkg] = dirname(require2.resolve(`${pkg}/package.json`));
62
+ } catch {
63
+ }
64
+ }
65
+ return { alias, dedupe: dedupePkgs };
66
+ }
53
67
  function createDevConfig(overrides = {}) {
54
68
  const root = resolveClientPath();
55
69
  const { userRoot, mermaidStrategy, ...viteOverrides } = overrides;
70
+ const { alias: dedupeAlias, dedupe } = dedupeResolve();
56
71
  return {
57
72
  root,
58
73
  plugins: [
@@ -79,8 +94,10 @@ function createDevConfig(overrides = {}) {
79
94
  },
80
95
  resolve: {
81
96
  alias: {
82
- "@clearify": resolve(root, "..")
83
- }
97
+ "@clearify": resolve(root, ".."),
98
+ ...dedupeAlias
99
+ },
100
+ dedupe
84
101
  },
85
102
  ...viteOverrides
86
103
  };
@@ -103,6 +120,7 @@ async function createServer(options = {}) {
103
120
  }
104
121
  export {
105
122
  buildSite as build,
123
+ checkFrontmatter,
106
124
  checkLinks,
107
125
  createServer,
108
126
  init
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  init
3
- } from "../chunk-QIAMD2Q6.js";
3
+ } from "../chunk-3CW4MOO4.js";
4
4
  export {
5
5
  init
6
6
  };
@@ -70,11 +70,18 @@ interface NavigationItem {
70
70
  badgeColor?: string;
71
71
  children?: NavigationItem[];
72
72
  }
73
+ type DocCategory = 'documentation' | 'internal' | 'plan' | 'research' | 'decision' | 'roadmap' | 'changelog';
73
74
  interface PageFrontmatter {
74
75
  title?: string;
75
76
  description?: string;
76
77
  icon?: string;
77
78
  order?: number;
79
+ summary?: string;
80
+ category?: DocCategory;
81
+ tags?: string[];
82
+ projects?: string[];
83
+ status?: 'active' | 'superseded' | 'archived';
84
+ date?: string;
78
85
  }
79
86
  interface RouteEntry {
80
87
  path: string;
@@ -106,4 +113,4 @@ interface HubConfig {
106
113
  }
107
114
  declare function defineConfig(config: Partial<ClearifyConfig>): Partial<ClearifyConfig>;
108
115
 
109
- export { type ClearifyConfig, type HubConfig, type HubProject, type NavigationItem, type PageFrontmatter, type RemoteGitSource, type ResolvedSection, type RouteEntry, type SectionConfig, type SectionNavigation, defineConfig };
116
+ export { type ClearifyConfig, type DocCategory, type HubConfig, type HubProject, type NavigationItem, type PageFrontmatter, type RemoteGitSource, type ResolvedSection, type RouteEntry, type SectionConfig, type SectionNavigation, defineConfig };
@@ -1,11 +1,11 @@
1
1
  import {
2
2
  clearifyPlugin
3
- } from "../chunk-2RAA6477.js";
3
+ } from "../chunk-PFX7LHRZ.js";
4
4
  import "../chunk-B2Q23JW3.js";
5
5
  import "../chunk-PVTUVUBK.js";
6
6
  import "../chunk-AJBCHKBP.js";
7
7
  import "../chunk-V7LLYIRO.js";
8
- import "../chunk-QNC5QRIO.js";
8
+ import "../chunk-QTGT76RY.js";
9
9
  import "../chunk-5TD7NQIW.js";
10
10
  export {
11
11
  clearifyPlugin
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marlinjai/clearify",
3
- "version": "1.10.0",
3
+ "version": "1.11.0",
4
4
  "description": "An open-source documentation site generator. Turn markdown into beautiful docs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,7 +24,9 @@
24
24
  ],
25
25
  "scripts": {
26
26
  "build": "tsup",
27
- "dev:self": "node bin/clearify.js dev"
27
+ "dev:self": "node bin/clearify.js dev",
28
+ "prepare": "husky",
29
+ "release": "semantic-release"
28
30
  },
29
31
  "dependencies": {
30
32
  "@mdx-js/react": "^3.1.1",
@@ -63,12 +65,18 @@
63
65
  }
64
66
  },
65
67
  "devDependencies": {
68
+ "@commitlint/cli": "^20.4.3",
69
+ "@commitlint/config-conventional": "^20.4.3",
70
+ "@semantic-release/changelog": "^6.0.3",
71
+ "@semantic-release/git": "^10.0.1",
66
72
  "@types/mdast": "^4.0.4",
67
73
  "@types/node": "^25.2.2",
68
74
  "@types/react": "^18.3.18",
69
75
  "@types/react-dom": "^18.3.5",
70
76
  "esbuild": "^0.27.3",
77
+ "husky": "^9.1.7",
71
78
  "puppeteer": "^24.37.2",
79
+ "semantic-release": "^25.0.3",
72
80
  "tsup": "^8.3.6",
73
81
  "typescript": "^5.7.3"
74
82
  },
@@ -449,25 +449,26 @@ function NavItem({ item, depth, onNavigate, expandedGroups, onToggleGroup, group
449
449
  display: 'flex',
450
450
  alignItems: 'center',
451
451
  width: '100%',
452
- padding: '0.4rem 0.75rem',
452
+ padding: depth === 0 ? '0.4rem 0.75rem' : '0.35rem 0.75rem',
453
+ paddingLeft: `${0.75 + depth * 0.75}rem`,
453
454
  background: 'none',
454
455
  border: 'none',
455
456
  cursor: 'pointer',
456
- fontSize: '0.6875rem',
457
- fontWeight: 600,
458
- textTransform: 'uppercase',
459
- letterSpacing: '0.06em',
460
- color: 'var(--clearify-text-tertiary)',
461
- marginTop: depth === 0 ? '1.25rem' : 0,
457
+ fontSize: depth === 0 ? '0.6875rem' : '0.75rem',
458
+ fontWeight: depth === 0 ? 600 : 500,
459
+ textTransform: depth === 0 ? 'uppercase' : 'none',
460
+ letterSpacing: depth === 0 ? '0.06em' : '0.01em',
461
+ color: depth === 0 ? 'var(--clearify-text-tertiary)' : 'var(--clearify-text-secondary)',
462
+ marginTop: depth === 0 ? '1.25rem' : '0.375rem',
462
463
  borderRadius: 'var(--clearify-radius-sm)',
463
464
  transition: 'color 0.15s',
464
465
  }}
465
- onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--clearify-text-secondary)')}
466
- onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--clearify-text-tertiary)')}
466
+ onMouseEnter={(e) => (e.currentTarget.style.color = depth === 0 ? 'var(--clearify-text-secondary)' : 'var(--clearify-text)')}
467
+ onMouseLeave={(e) => (e.currentTarget.style.color = depth === 0 ? 'var(--clearify-text-tertiary)' : 'var(--clearify-text-secondary)')}
467
468
  >
468
469
  <svg
469
- width="12"
470
- height="12"
470
+ width={depth === 0 ? '12' : '10'}
471
+ height={depth === 0 ? '12' : '10'}
471
472
  viewBox="0 0 24 24"
472
473
  fill="none"
473
474
  stroke="currentColor"
@@ -479,6 +480,7 @@ function NavItem({ item, depth, onNavigate, expandedGroups, onToggleGroup, group
479
480
  transition: 'transform 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
480
481
  transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)',
481
482
  flexShrink: 0,
483
+ opacity: depth === 0 ? 1 : 0.7,
482
484
  }}
483
485
  >
484
486
  <polyline points="9 18 15 12 9 6" />
@@ -1,136 +0,0 @@
1
- import {
2
- loadUserConfig,
3
- resolveConfig,
4
- resolveSections
5
- } from "./chunk-PVTUVUBK.js";
6
- import {
7
- scanDocs
8
- } from "./chunk-QNC5QRIO.js";
9
-
10
- // src/node/check.ts
11
- import { resolve } from "path";
12
- import { readFileSync, existsSync } from "fs";
13
- async function checkLinks() {
14
- const userRoot = process.cwd();
15
- const userConfig = await loadUserConfig(userRoot);
16
- const config = resolveConfig(userConfig, userRoot);
17
- const sections = await resolveSections(config, userRoot);
18
- const validRoutes = /* @__PURE__ */ new Set();
19
- const allDocs = [];
20
- for (const section of sections) {
21
- const docs = scanDocs(section.docsDir, section.exclude, section.basePath);
22
- for (const doc of docs) {
23
- validRoutes.add(doc.routePath);
24
- allDocs.push(doc);
25
- }
26
- }
27
- validRoutes.add("/");
28
- const changelogPath = resolve(userRoot, "CHANGELOG.md");
29
- if (existsSync(changelogPath)) {
30
- validRoutes.add("/changelog");
31
- }
32
- const expandedRoutes = new Set(validRoutes);
33
- for (const route of validRoutes) {
34
- expandedRoutes.add(route.replace(/\/$/, ""));
35
- if (route !== "/") expandedRoutes.add(route + "/");
36
- }
37
- const mdLinkRegex = /\[(?:[^\]]*)\]\(([^)]+)\)/g;
38
- const hrefRegex = /href=["']([^"']+)["']/g;
39
- const brokenLinks = [];
40
- for (const doc of allDocs) {
41
- if (!existsSync(doc.filePath)) continue;
42
- const content = readFileSync(doc.filePath, "utf-8");
43
- const lines = content.split("\n");
44
- for (let i = 0; i < lines.length; i++) {
45
- const line = lines[i];
46
- const lineNum = i + 1;
47
- if (line.trimStart().startsWith("```")) continue;
48
- for (const regex of [mdLinkRegex, hrefRegex]) {
49
- regex.lastIndex = 0;
50
- let match;
51
- while ((match = regex.exec(line)) !== null) {
52
- const rawLink = match[1].split(/[\s"'#]/)[0];
53
- if (!rawLink || rawLink.startsWith("http://") || rawLink.startsWith("https://") || rawLink.startsWith("#") || rawLink.startsWith("mailto:") || rawLink.startsWith("tel:") || rawLink.startsWith("//") || rawLink.startsWith("{") || rawLink.startsWith("data:")) {
54
- continue;
55
- }
56
- if (/\.(png|jpe?g|gif|svg|webp|ico|pdf|zip|tar|gz)$/i.test(rawLink)) {
57
- continue;
58
- }
59
- let normalized = rawLink;
60
- normalized = normalized.replace(/\.(mdx?|html?)$/, "");
61
- if (!normalized.startsWith("/")) {
62
- const docDir = doc.routePath.substring(0, doc.routePath.lastIndexOf("/")) || "/";
63
- normalized = docDir === "/" ? "/" + normalized : docDir + "/" + normalized;
64
- }
65
- const check = normalized === "/" ? "/" : normalized.replace(/\/$/, "");
66
- const checkNoIndex = check.replace(/\/index$/, "") || "/";
67
- if (!expandedRoutes.has(check) && !expandedRoutes.has(checkNoIndex)) {
68
- const suggestion = findClosest(check, validRoutes);
69
- brokenLinks.push({
70
- file: doc.filePath,
71
- line: lineNum,
72
- link: rawLink,
73
- ...suggestion ? { suggestion } : {}
74
- });
75
- }
76
- }
77
- }
78
- }
79
- }
80
- if (brokenLinks.length === 0) {
81
- console.log("\n No broken links found.\n");
82
- return;
83
- }
84
- console.log(`
85
- Found ${brokenLinks.length} broken link${brokenLinks.length === 1 ? "" : "s"}:
86
- `);
87
- const byFile = /* @__PURE__ */ new Map();
88
- for (const bl of brokenLinks) {
89
- const relative = bl.file.replace(userRoot + "/", "");
90
- if (!byFile.has(relative)) byFile.set(relative, []);
91
- byFile.get(relative).push(bl);
92
- }
93
- for (const [file, links] of byFile) {
94
- console.log(` ${file}`);
95
- for (const link of links) {
96
- const suggestion = link.suggestion ? ` (did you mean ${link.suggestion}?)` : "";
97
- console.log(` line ${link.line}: ${link.link}${suggestion}`);
98
- }
99
- console.log();
100
- }
101
- process.exitCode = 1;
102
- }
103
- function findClosest(target, routes) {
104
- let best;
105
- let bestScore = Infinity;
106
- const targetSegments = target.split("/").filter(Boolean);
107
- for (const route of routes) {
108
- const routeSegments = route.split("/").filter(Boolean);
109
- const dist = levenshtein(target, route);
110
- const common = targetSegments.filter((s) => routeSegments.includes(s)).length;
111
- const score = dist - common * 2;
112
- if (score < bestScore) {
113
- bestScore = score;
114
- best = route;
115
- }
116
- }
117
- if (best && bestScore < target.length * 0.6) return best;
118
- return void 0;
119
- }
120
- function levenshtein(a, b) {
121
- const m = a.length;
122
- const n = b.length;
123
- const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
124
- for (let i = 0; i <= m; i++) dp[i][0] = i;
125
- for (let j = 0; j <= n; j++) dp[0][j] = j;
126
- for (let i = 1; i <= m; i++) {
127
- for (let j = 1; j <= n; j++) {
128
- dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
129
- }
130
- }
131
- return dp[m][n];
132
- }
133
-
134
- export {
135
- checkLinks
136
- };