@marlinjai/clearify 1.11.1 → 1.12.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,12 +1,3 @@
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
-
10
1
  # Clearify
11
2
 
12
3
  An open-source documentation site generator. Turn markdown into beautiful docs.
@@ -0,0 +1,225 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "type": "object",
4
+ "properties": {
5
+ "name": {
6
+ "type": "string"
7
+ },
8
+ "siteUrl": {
9
+ "type": "string"
10
+ },
11
+ "theme": {
12
+ "type": "object",
13
+ "properties": {
14
+ "primaryColor": {
15
+ "type": "string"
16
+ },
17
+ "mode": {
18
+ "type": "string",
19
+ "enum": [
20
+ "light",
21
+ "dark",
22
+ "auto"
23
+ ]
24
+ }
25
+ },
26
+ "additionalProperties": false
27
+ },
28
+ "logo": {
29
+ "type": "object",
30
+ "properties": {
31
+ "light": {
32
+ "type": "string"
33
+ },
34
+ "dark": {
35
+ "type": "string"
36
+ }
37
+ },
38
+ "additionalProperties": false
39
+ },
40
+ "links": {
41
+ "type": "object",
42
+ "propertyNames": {
43
+ "type": "string"
44
+ },
45
+ "additionalProperties": {
46
+ "type": "string"
47
+ }
48
+ },
49
+ "sections": {
50
+ "type": "array",
51
+ "items": {
52
+ "type": "object",
53
+ "properties": {
54
+ "label": {
55
+ "type": "string"
56
+ },
57
+ "docsDir": {
58
+ "type": "string"
59
+ },
60
+ "basePath": {
61
+ "type": "string"
62
+ },
63
+ "draft": {
64
+ "type": "boolean"
65
+ },
66
+ "sitemap": {
67
+ "type": "boolean"
68
+ },
69
+ "exclude": {
70
+ "type": "array",
71
+ "items": {
72
+ "type": "string"
73
+ }
74
+ },
75
+ "git": {
76
+ "type": "object",
77
+ "properties": {
78
+ "repo": {
79
+ "type": "string"
80
+ },
81
+ "ref": {
82
+ "type": "string"
83
+ },
84
+ "path": {
85
+ "type": "string"
86
+ },
87
+ "sparse": {
88
+ "type": "boolean"
89
+ }
90
+ },
91
+ "required": [
92
+ "repo"
93
+ ],
94
+ "additionalProperties": false
95
+ }
96
+ },
97
+ "required": [
98
+ "label",
99
+ "docsDir"
100
+ ],
101
+ "additionalProperties": false
102
+ }
103
+ },
104
+ "hub": {
105
+ "type": "object",
106
+ "properties": {
107
+ "projects": {
108
+ "default": [],
109
+ "type": "array",
110
+ "items": {
111
+ "type": "object",
112
+ "properties": {
113
+ "name": {
114
+ "type": "string"
115
+ },
116
+ "description": {
117
+ "type": "string"
118
+ },
119
+ "href": {
120
+ "type": "string"
121
+ },
122
+ "repo": {
123
+ "type": "string"
124
+ },
125
+ "status": {
126
+ "default": "active",
127
+ "type": "string",
128
+ "enum": [
129
+ "active",
130
+ "beta",
131
+ "planned",
132
+ "deprecated"
133
+ ]
134
+ },
135
+ "icon": {
136
+ "type": "string"
137
+ },
138
+ "tags": {
139
+ "type": "array",
140
+ "items": {
141
+ "type": "string"
142
+ }
143
+ },
144
+ "group": {
145
+ "type": "string"
146
+ },
147
+ "mode": {
148
+ "type": "string",
149
+ "enum": [
150
+ "link",
151
+ "embed",
152
+ "inject"
153
+ ]
154
+ },
155
+ "git": {
156
+ "type": "object",
157
+ "properties": {
158
+ "repo": {
159
+ "type": "string"
160
+ },
161
+ "ref": {
162
+ "type": "string"
163
+ },
164
+ "path": {
165
+ "type": "string"
166
+ },
167
+ "sparse": {
168
+ "type": "boolean"
169
+ }
170
+ },
171
+ "required": [
172
+ "repo"
173
+ ],
174
+ "additionalProperties": false
175
+ },
176
+ "embedSections": {
177
+ "anyOf": [
178
+ {
179
+ "type": "string",
180
+ "const": "all"
181
+ },
182
+ {
183
+ "type": "string",
184
+ "const": "public"
185
+ },
186
+ {
187
+ "type": "array",
188
+ "items": {
189
+ "type": "string"
190
+ }
191
+ }
192
+ ]
193
+ },
194
+ "injectInto": {
195
+ "type": "string"
196
+ },
197
+ "docsPath": {
198
+ "type": "string"
199
+ }
200
+ },
201
+ "required": [
202
+ "name",
203
+ "description",
204
+ "status"
205
+ ],
206
+ "additionalProperties": false
207
+ }
208
+ },
209
+ "scan": {
210
+ "type": "string"
211
+ },
212
+ "cacheDir": {
213
+ "type": "string"
214
+ }
215
+ },
216
+ "required": [
217
+ "projects"
218
+ ],
219
+ "additionalProperties": false
220
+ }
221
+ },
222
+ "additionalProperties": false,
223
+ "$id": "https://docs.lumitra.co/schemas/clearify-data.json",
224
+ "title": "Clearify Data Config"
225
+ }
@@ -6,7 +6,7 @@ import {
6
6
  import { z } from "zod";
7
7
  import { resolve, basename, dirname } from "path";
8
8
  import { pathToFileURL } from "url";
9
- import { existsSync, readFileSync, rmSync } from "fs";
9
+ import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync, rmSync, symlinkSync, readdirSync, statSync } from "fs";
10
10
  import { tmpdir } from "os";
11
11
  import { randomBytes } from "crypto";
12
12
  var NavigationItemSchema = z.lazy(
@@ -51,13 +51,15 @@ var HubProjectSchema = z.object({
51
51
  icon: z.string().optional(),
52
52
  tags: z.array(z.string()).optional(),
53
53
  group: z.string().optional(),
54
- mode: z.enum(["link", "embed"]).optional(),
54
+ mode: z.enum(["link", "embed", "inject"]).optional(),
55
55
  git: RemoteGitSourceSchema.optional(),
56
56
  embedSections: z.union([
57
57
  z.literal("all"),
58
58
  z.literal("public"),
59
59
  z.array(z.string())
60
- ]).optional()
60
+ ]).optional(),
61
+ injectInto: z.string().optional(),
62
+ docsPath: z.string().optional()
61
63
  });
62
64
  var HubProjectPartialSchema = z.object({
63
65
  description: z.string(),
@@ -100,6 +102,52 @@ var ClearifyConfigSchema = z.object({
100
102
  customCss: z.string().optional(),
101
103
  headTags: z.array(z.string()).optional()
102
104
  });
105
+ var ClearifyDataSchema = z.object({
106
+ name: z.string().optional(),
107
+ siteUrl: z.string().optional(),
108
+ theme: z.object({
109
+ primaryColor: z.string().optional(),
110
+ mode: z.enum(["light", "dark", "auto"]).optional()
111
+ }).optional(),
112
+ logo: z.object({
113
+ light: z.string().optional(),
114
+ dark: z.string().optional()
115
+ }).optional(),
116
+ links: z.record(z.string(), z.string()).optional(),
117
+ sections: z.array(SectionConfigSchema).optional(),
118
+ hub: HubConfigSchema
119
+ });
120
+ function deepMergeJsonWins(base, json) {
121
+ const result = { ...base };
122
+ for (const key of Object.keys(json)) {
123
+ const jsonVal = json[key];
124
+ if (jsonVal === void 0) continue;
125
+ const baseVal = base[key];
126
+ if (baseVal !== null && jsonVal !== null && typeof baseVal === "object" && typeof jsonVal === "object" && !Array.isArray(baseVal) && !Array.isArray(jsonVal)) {
127
+ result[key] = deepMergeJsonWins(baseVal, jsonVal);
128
+ } else {
129
+ result[key] = jsonVal;
130
+ }
131
+ }
132
+ return result;
133
+ }
134
+ function loadDataConfig(root) {
135
+ const dataPath = resolve(root, "clearify.data.json");
136
+ if (!existsSync(dataPath)) return {};
137
+ try {
138
+ const raw = JSON.parse(readFileSync(dataPath, "utf-8"));
139
+ return ClearifyDataSchema.parse(raw);
140
+ } catch {
141
+ return {};
142
+ }
143
+ }
144
+ function writeDataConfig(root, data) {
145
+ const validated = ClearifyDataSchema.parse(data);
146
+ const dataPath = resolve(root, "clearify.data.json");
147
+ const tmpPath = dataPath + ".tmp";
148
+ writeFileSync(tmpPath, JSON.stringify(validated, null, 2) + "\n", "utf-8");
149
+ renameSync(tmpPath, dataPath);
150
+ }
103
151
  var defaultConfig = {
104
152
  name: "Documentation",
105
153
  docsDir: "./docs",
@@ -176,20 +224,24 @@ async function loadUserConfig(root) {
176
224
  "clearify.config.js",
177
225
  "clearify.config.mjs"
178
226
  ];
227
+ let tsConfig = {};
179
228
  for (const file of configFiles) {
180
229
  const configPath = resolve(root, file);
181
230
  if (existsSync(configPath)) {
182
231
  try {
183
232
  if (file.endsWith(".ts")) {
184
- return await loadTsConfig(configPath);
233
+ tsConfig = await loadTsConfig(configPath);
234
+ } else {
235
+ const mod = await import(pathToFileURL(configPath).href);
236
+ tsConfig = mod.default ?? mod;
185
237
  }
186
- const mod = await import(pathToFileURL(configPath).href);
187
- return mod.default ?? mod;
238
+ break;
188
239
  } catch {
189
240
  }
190
241
  }
191
242
  }
192
- return {};
243
+ const jsonData = loadDataConfig(root);
244
+ return deepMergeJsonWins(tsConfig, jsonData);
193
245
  }
194
246
  function resolveConfig(userConfig = {}, root) {
195
247
  const parsed = ClearifyConfigSchema.parse(userConfig);
@@ -247,8 +299,43 @@ async function resolveSections(config, root) {
247
299
  }
248
300
  ];
249
301
  }
302
+ function buildStagingOverlay(stagingDir, originalDir, injections) {
303
+ if (existsSync(stagingDir)) {
304
+ rmSync(stagingDir, { recursive: true, force: true });
305
+ }
306
+ mkdirSync(stagingDir, { recursive: true });
307
+ const ancestorDirs = /* @__PURE__ */ new Set();
308
+ for (const targetRelPath of injections.keys()) {
309
+ const parts = targetRelPath.split("/");
310
+ for (let i = 1; i <= parts.length; i++) {
311
+ ancestorDirs.add(parts.slice(0, i).join("/"));
312
+ }
313
+ }
314
+ function mirrorDir(srcDir, destDir, relPrefix) {
315
+ if (!existsSync(srcDir)) return;
316
+ const entries = readdirSync(srcDir);
317
+ for (const entry of entries) {
318
+ const srcPath = resolve(srcDir, entry);
319
+ const destPath = resolve(destDir, entry);
320
+ const relPath = relPrefix ? `${relPrefix}/${entry}` : entry;
321
+ if (ancestorDirs.has(relPath) && statSync(srcPath).isDirectory()) {
322
+ mkdirSync(destPath, { recursive: true });
323
+ mirrorDir(srcPath, destPath, relPath);
324
+ } else {
325
+ symlinkSync(srcPath, destPath);
326
+ }
327
+ }
328
+ }
329
+ mirrorDir(originalDir, stagingDir, "");
330
+ for (const [relPath, sourcePath] of injections) {
331
+ const destPath = resolve(stagingDir, relPath);
332
+ const parentDir = dirname(destPath);
333
+ mkdirSync(parentDir, { recursive: true });
334
+ symlinkSync(sourcePath, destPath);
335
+ }
336
+ }
250
337
  async function scanHubProjects(config, root) {
251
- if (!config.hub?.scan && !config.hub?.projects?.some((p) => p.mode === "embed")) {
338
+ if (!config.hub?.scan && !config.hub?.projects?.some((p) => p.mode === "embed" || p.mode === "inject")) {
252
339
  return config;
253
340
  }
254
341
  const scannedProjects = [];
@@ -320,10 +407,69 @@ async function scanHubProjects(config, root) {
320
407
  );
321
408
  }
322
409
  }
323
- const finalSections = [...config.sections ?? [], ...embedSections];
410
+ const injectionsBySection = /* @__PURE__ */ new Map();
411
+ for (const project of merged) {
412
+ if (project.mode !== "inject" || !project.git) continue;
413
+ const cacheDir = config.hub?.cacheDir ? resolve(root, config.hub.cacheDir) : resolve(root, "node_modules/.cache/clearify-remote");
414
+ try {
415
+ const { resolveRemoteSource } = await import("./core/remote.js");
416
+ const { localPath, cached } = await resolveRemoteSource(project.git, cacheDir);
417
+ const docsPath = project.docsPath ?? "docs";
418
+ const absoluteDocsPath = resolve(localPath, docsPath);
419
+ if (!existsSync(absoluteDocsPath)) {
420
+ console.warn(
421
+ ` \u26A0 Hub inject "${project.name}": docs path "${docsPath}" not found in cloned repo.`
422
+ );
423
+ continue;
424
+ }
425
+ const targetLabel = project.injectInto;
426
+ if (!targetLabel) {
427
+ console.warn(` \u26A0 Hub inject "${project.name}": missing injectInto field.`);
428
+ continue;
429
+ }
430
+ if (!injectionsBySection.has(targetLabel)) {
431
+ injectionsBySection.set(targetLabel, /* @__PURE__ */ new Map());
432
+ }
433
+ const slugifiedName = slugify(project.name);
434
+ const relPath = project.group ? `projects/${project.group}/${slugifiedName}` : `projects/${slugifiedName}`;
435
+ injectionsBySection.get(targetLabel).set(relPath, absoluteDocsPath);
436
+ console.log(
437
+ ` Hub inject "${project.name}": ${cached ? "cached" : "cloned"} \u2192 ${relPath}`
438
+ );
439
+ } catch (err) {
440
+ console.warn(
441
+ ` \u26A0 Hub inject "${project.name}" failed:`,
442
+ err instanceof Error ? err.message : err
443
+ );
444
+ }
445
+ }
446
+ let modifiedSections = config.sections ? [...config.sections] : void 0;
447
+ if (injectionsBySection.size > 0 && modifiedSections) {
448
+ for (const [sectionLabel, injections] of injectionsBySection) {
449
+ const sectionIdx = modifiedSections.findIndex((s) => s.label === sectionLabel);
450
+ if (sectionIdx === -1) {
451
+ console.warn(` \u26A0 Hub inject: target section "${sectionLabel}" not found.`);
452
+ continue;
453
+ }
454
+ const section = modifiedSections[sectionIdx];
455
+ const sectionId = slugify(section.label);
456
+ const originalDocsDir = section.docsDir.startsWith("/") ? section.docsDir : resolve(root, section.docsDir);
457
+ const stagingDir = resolve(root, `node_modules/.cache/clearify-inject/${sectionId}`);
458
+ buildStagingOverlay(stagingDir, originalDocsDir, injections);
459
+ modifiedSections[sectionIdx] = {
460
+ ...section,
461
+ docsDir: stagingDir
462
+ };
463
+ console.log(
464
+ ` Hub inject: staging overlay for "${sectionLabel}" \u2192 ${stagingDir}`
465
+ );
466
+ }
467
+ }
468
+ const finalSections = [...modifiedSections ?? config.sections ?? [], ...embedSections];
469
+ const sectionsChanged = embedSections.length > 0 || injectionsBySection.size > 0;
324
470
  return {
325
471
  ...config,
326
- ...embedSections.length > 0 ? { sections: finalSections } : {},
472
+ ...sectionsChanged ? { sections: finalSections } : {},
327
473
  hub: {
328
474
  ...config.hub,
329
475
  projects: merged
@@ -332,6 +478,10 @@ async function scanHubProjects(config, root) {
332
478
  }
333
479
 
334
480
  export {
481
+ ClearifyDataSchema,
482
+ deepMergeJsonWins,
483
+ loadDataConfig,
484
+ writeDataConfig,
335
485
  defaultConfig,
336
486
  loadUserConfig,
337
487
  resolveConfig,
@@ -2,7 +2,7 @@ import {
2
2
  loadUserConfig,
3
3
  resolveConfig,
4
4
  resolveSections
5
- } from "./chunk-PVTUVUBK.js";
5
+ } from "./chunk-2WLFSQS7.js";
6
6
  import {
7
7
  scanDocs
8
8
  } from "./chunk-QTGT76RY.js";
@@ -8,12 +8,12 @@ import {
8
8
  } from "./chunk-SCZZB7OE.js";
9
9
  import {
10
10
  clearifyPlugin
11
- } from "./chunk-PFX7LHRZ.js";
11
+ } from "./chunk-XYS5SV4M.js";
12
12
  import {
13
13
  loadUserConfig,
14
14
  resolveConfig,
15
15
  resolveSections
16
- } from "./chunk-PVTUVUBK.js";
16
+ } from "./chunk-2WLFSQS7.js";
17
17
  import {
18
18
  buildSectionData
19
19
  } from "./chunk-QTGT76RY.js";
@@ -246,6 +246,12 @@ async function buildSite() {
246
246
  alias: { "@clearify": resolve(root, "..") },
247
247
  dedupe
248
248
  },
249
+ ssr: {
250
+ // Bundle all deps into the SSR output so pre-rendering works in any
251
+ // host project, even if it doesn't have react/react-router installed.
252
+ // The SSR bundle is temporary and deleted after pre-rendering.
253
+ noExternal: true
254
+ },
249
255
  build: {
250
256
  outDir: ssrOutDir,
251
257
  ssr: resolveClientPath("entry-server.tsx"),
@@ -2,18 +2,23 @@ import {
2
2
  parseOpenAPISpec
3
3
  } from "./chunk-B2Q23JW3.js";
4
4
  import {
5
+ ClearifyDataSchema,
6
+ deepMergeJsonWins,
7
+ loadDataConfig,
5
8
  loadUserConfig,
6
9
  resolveConfig,
7
10
  resolveSections,
8
- scanHubProjects
9
- } from "./chunk-PVTUVUBK.js";
11
+ scanHubProjects,
12
+ writeDataConfig
13
+ } from "./chunk-2WLFSQS7.js";
10
14
  import {
11
15
  buildSectionData
12
16
  } from "./chunk-QTGT76RY.js";
13
17
 
14
18
  // src/vite-plugin/index.ts
15
- import { resolve } from "path";
16
- import { existsSync, readFileSync } from "fs";
19
+ import { resolve, dirname } from "path";
20
+ import { fileURLToPath } from "url";
21
+ import { existsSync, readFileSync, readdirSync, statSync } from "fs";
17
22
  import { dereference } from "@scalar/openapi-parser";
18
23
  var VIRTUAL_CONFIG = "virtual:clearify/config";
19
24
  var RESOLVED_VIRTUAL_CONFIG = "\0" + VIRTUAL_CONFIG;
@@ -29,6 +34,8 @@ var VIRTUAL_OPENAPI_SPEC = "virtual:clearify/openapi-spec";
29
34
  var RESOLVED_VIRTUAL_OPENAPI_SPEC = "\0" + VIRTUAL_OPENAPI_SPEC;
30
35
  var VIRTUAL_HUB = "virtual:clearify/hub";
31
36
  var RESOLVED_VIRTUAL_HUB = "\0" + VIRTUAL_HUB;
37
+ var VIRTUAL_ADMIN_ENABLED = "virtual:clearify/admin-enabled";
38
+ var RESOLVED_VIRTUAL_ADMIN_ENABLED = "\0" + VIRTUAL_ADMIN_ENABLED;
32
39
  function findFirstNavPath(items) {
33
40
  for (const item of items) {
34
41
  if (item.path) return item.path;
@@ -241,6 +248,7 @@ ${routeEntries.join(",\n")}
241
248
  if (id === VIRTUAL_MERMAID_SVGS) return RESOLVED_VIRTUAL_MERMAID_SVGS;
242
249
  if (id === VIRTUAL_OPENAPI_SPEC) return RESOLVED_VIRTUAL_OPENAPI_SPEC;
243
250
  if (id === VIRTUAL_HUB) return RESOLVED_VIRTUAL_HUB;
251
+ if (id === VIRTUAL_ADMIN_ENABLED) return RESOLVED_VIRTUAL_ADMIN_ENABLED;
244
252
  return null;
245
253
  },
246
254
  async load(id) {
@@ -266,6 +274,10 @@ ${routeEntries.join(",\n")}
266
274
  const hubData = config.hub ?? null;
267
275
  return `export default ${JSON.stringify(hubData)};`;
268
276
  }
277
+ if (id === RESOLVED_VIRTUAL_ADMIN_ENABLED) {
278
+ const enabled = resolvedViteConfig.command === "serve";
279
+ return `export default ${enabled};`;
280
+ }
269
281
  return null;
270
282
  },
271
283
  configureServer(server) {
@@ -410,6 +422,118 @@ ${routeEntries.join(",\n")}
410
422
  });
411
423
  }
412
424
  });
425
+ const dataJsonPath = resolve(userRoot, "clearify.data.json");
426
+ server.watcher.add(dataJsonPath);
427
+ server.watcher.on("change", (changedPath) => {
428
+ if (changedPath === dataJsonPath) {
429
+ for (const resolvedId of [
430
+ RESOLVED_VIRTUAL_CONFIG,
431
+ RESOLVED_VIRTUAL_ROUTES,
432
+ RESOLVED_VIRTUAL_NAVIGATION,
433
+ RESOLVED_VIRTUAL_SEARCH_INDEX
434
+ ]) {
435
+ const mod = server.moduleGraph.getModuleById(resolvedId);
436
+ if (mod) server.moduleGraph.invalidateModule(mod);
437
+ }
438
+ server.ws.send({ type: "full-reload" });
439
+ }
440
+ });
441
+ function readJsonBody(req) {
442
+ return new Promise((resolve2, reject) => {
443
+ const chunks = [];
444
+ req.on("data", (chunk) => chunks.push(chunk));
445
+ req.on("end", () => {
446
+ try {
447
+ resolve2(JSON.parse(Buffer.concat(chunks).toString()));
448
+ } catch {
449
+ reject(new Error("Invalid JSON"));
450
+ }
451
+ });
452
+ req.on("error", reject);
453
+ });
454
+ }
455
+ server.middlewares.use(async (req, res, next) => {
456
+ const url = req.url?.split("?") ?? [""];
457
+ const pathname = url[0];
458
+ const query = new URLSearchParams(url[1] ?? "");
459
+ if (!pathname.startsWith("/__clearify/api/")) return next();
460
+ res.setHeader("Content-Type", "application/json");
461
+ try {
462
+ if (req.method === "GET" && pathname === "/__clearify/api/config") {
463
+ res.end(JSON.stringify(config));
464
+ return;
465
+ }
466
+ if (req.method === "GET" && pathname === "/__clearify/api/config/data") {
467
+ const data = loadDataConfig(userRoot);
468
+ res.end(JSON.stringify(data));
469
+ return;
470
+ }
471
+ if (req.method === "PUT" && pathname === "/__clearify/api/config/data") {
472
+ const body = await readJsonBody(req);
473
+ const result = ClearifyDataSchema.safeParse(body);
474
+ if (!result.success) {
475
+ res.statusCode = 400;
476
+ res.end(JSON.stringify({ error: "Validation failed", issues: result.error.issues }));
477
+ return;
478
+ }
479
+ writeDataConfig(userRoot, body);
480
+ res.end(JSON.stringify({ ok: true }));
481
+ return;
482
+ }
483
+ if (req.method === "PATCH" && pathname === "/__clearify/api/config/data") {
484
+ const body = await readJsonBody(req);
485
+ const existing = loadDataConfig(userRoot);
486
+ const merged = deepMergeJsonWins(existing, body);
487
+ const result = ClearifyDataSchema.safeParse(merged);
488
+ if (!result.success) {
489
+ res.statusCode = 400;
490
+ res.end(JSON.stringify({ error: "Validation failed", issues: result.error.issues }));
491
+ return;
492
+ }
493
+ writeDataConfig(userRoot, merged);
494
+ res.end(JSON.stringify({ ok: true }));
495
+ return;
496
+ }
497
+ if (req.method === "GET" && pathname === "/__clearify/api/config/schema") {
498
+ const pkgDir = dirname(fileURLToPath(import.meta.url));
499
+ const schemaPath = resolve(pkgDir, "../../dist/config-schema.json");
500
+ if (existsSync(schemaPath)) {
501
+ res.end(readFileSync(schemaPath, "utf-8"));
502
+ } else {
503
+ res.statusCode = 404;
504
+ res.end(JSON.stringify({ error: "Schema file not found" }));
505
+ }
506
+ return;
507
+ }
508
+ if (req.method === "GET" && pathname === "/__clearify/api/fs/dirs") {
509
+ const rootParam = query.get("root") ?? ".";
510
+ const target = resolve(userRoot, rootParam);
511
+ if (!target.startsWith(userRoot)) {
512
+ res.statusCode = 403;
513
+ res.end(JSON.stringify({ error: "Path outside project root" }));
514
+ return;
515
+ }
516
+ if (!existsSync(target)) {
517
+ res.end(JSON.stringify({ dirs: [] }));
518
+ return;
519
+ }
520
+ const dirs = readdirSync(target).filter((entry) => {
521
+ try {
522
+ return statSync(resolve(target, entry)).isDirectory();
523
+ } catch {
524
+ return false;
525
+ }
526
+ });
527
+ res.end(JSON.stringify({ dirs }));
528
+ return;
529
+ }
530
+ res.statusCode = 404;
531
+ res.end(JSON.stringify({ error: "Not found" }));
532
+ } catch (err) {
533
+ res.statusCode = err.message === "Invalid JSON" ? 400 : 500;
534
+ res.end(JSON.stringify({ error: err.message ?? "Internal server error" }));
535
+ }
536
+ });
413
537
  return () => {
414
538
  server.middlewares.use((req, _res, next) => {
415
539
  if (req.method !== "GET" || !req.headers.accept?.includes("text/html")) {