@marlinjai/clearify 1.20.0 → 2.0.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.
@@ -116,9 +116,6 @@
116
116
  "description": {
117
117
  "type": "string"
118
118
  },
119
- "href": {
120
- "type": "string"
121
- },
122
119
  "repo": {
123
120
  "type": "string"
124
121
  },
@@ -144,64 +141,165 @@
144
141
  "group": {
145
142
  "type": "string"
146
143
  },
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"
144
+ "source": {
145
+ "oneOf": [
146
+ {
147
+ "type": "object",
148
+ "properties": {
149
+ "kind": {
150
+ "type": "string",
151
+ "const": "none"
152
+ }
153
+ },
154
+ "required": [
155
+ "kind"
156
+ ],
157
+ "additionalProperties": false
160
158
  },
161
- "ref": {
162
- "type": "string"
159
+ {
160
+ "type": "object",
161
+ "properties": {
162
+ "kind": {
163
+ "type": "string",
164
+ "const": "git"
165
+ },
166
+ "repo": {
167
+ "type": "string"
168
+ },
169
+ "ref": {
170
+ "type": "string"
171
+ },
172
+ "path": {
173
+ "type": "string"
174
+ },
175
+ "sparse": {
176
+ "type": "boolean"
177
+ }
178
+ },
179
+ "required": [
180
+ "kind",
181
+ "repo"
182
+ ],
183
+ "additionalProperties": false
163
184
  },
164
- "path": {
165
- "type": "string"
185
+ {
186
+ "type": "object",
187
+ "properties": {
188
+ "kind": {
189
+ "type": "string",
190
+ "const": "url"
191
+ },
192
+ "url": {
193
+ "type": "string"
194
+ }
195
+ },
196
+ "required": [
197
+ "kind",
198
+ "url"
199
+ ],
200
+ "additionalProperties": false
166
201
  },
167
- "sparse": {
168
- "type": "boolean"
202
+ {
203
+ "type": "object",
204
+ "properties": {
205
+ "kind": {
206
+ "type": "string",
207
+ "const": "inline"
208
+ },
209
+ "markdown": {
210
+ "type": "string"
211
+ }
212
+ },
213
+ "required": [
214
+ "kind",
215
+ "markdown"
216
+ ],
217
+ "additionalProperties": false
169
218
  }
170
- },
171
- "required": [
172
- "repo"
173
- ],
174
- "additionalProperties": false
219
+ ]
175
220
  },
176
- "embedSections": {
177
- "anyOf": [
221
+ "placement": {
222
+ "oneOf": [
178
223
  {
179
- "type": "string",
180
- "const": "all"
224
+ "type": "object",
225
+ "properties": {
226
+ "kind": {
227
+ "type": "string",
228
+ "const": "card"
229
+ },
230
+ "href": {
231
+ "type": "string"
232
+ }
233
+ },
234
+ "required": [
235
+ "kind",
236
+ "href"
237
+ ],
238
+ "additionalProperties": false
181
239
  },
182
240
  {
183
- "type": "string",
184
- "const": "public"
241
+ "type": "object",
242
+ "properties": {
243
+ "kind": {
244
+ "type": "string",
245
+ "const": "tab"
246
+ },
247
+ "sections": {
248
+ "anyOf": [
249
+ {
250
+ "type": "string",
251
+ "const": "all"
252
+ },
253
+ {
254
+ "type": "string",
255
+ "const": "public"
256
+ },
257
+ {
258
+ "type": "array",
259
+ "items": {
260
+ "type": "string"
261
+ }
262
+ }
263
+ ]
264
+ }
265
+ },
266
+ "required": [
267
+ "kind"
268
+ ],
269
+ "additionalProperties": false
185
270
  },
186
271
  {
187
- "type": "array",
188
- "items": {
189
- "type": "string"
190
- }
272
+ "type": "object",
273
+ "properties": {
274
+ "kind": {
275
+ "type": "string",
276
+ "const": "nested"
277
+ },
278
+ "into": {
279
+ "type": "string"
280
+ },
281
+ "docsPath": {
282
+ "type": "string"
283
+ },
284
+ "group": {
285
+ "type": "string"
286
+ }
287
+ },
288
+ "required": [
289
+ "kind",
290
+ "into"
291
+ ],
292
+ "additionalProperties": false
191
293
  }
192
294
  ]
193
- },
194
- "injectInto": {
195
- "type": "string"
196
- },
197
- "docsPath": {
198
- "type": "string"
199
295
  }
200
296
  },
201
297
  "required": [
202
298
  "name",
203
299
  "description",
204
- "status"
300
+ "status",
301
+ "source",
302
+ "placement"
205
303
  ],
206
304
  "additionalProperties": false
207
305
  }
@@ -8,12 +8,12 @@ import {
8
8
  } from "./chunk-SCZZB7OE.js";
9
9
  import {
10
10
  clearifyPlugin
11
- } from "./chunk-GWYMXKDL.js";
11
+ } from "./chunk-RW4IKHY4.js";
12
12
  import {
13
13
  loadUserConfig,
14
14
  resolveConfig,
15
15
  resolveSections
16
- } from "./chunk-2WML2D77.js";
16
+ } from "./chunk-PGTNVDNF.js";
17
17
  import {
18
18
  buildSectionData
19
19
  } from "./chunk-CTL4YL7C.js";
@@ -2,7 +2,7 @@ import {
2
2
  loadUserConfig,
3
3
  resolveConfig,
4
4
  resolveSections
5
- } from "./chunk-2WML2D77.js";
5
+ } from "./chunk-PGTNVDNF.js";
6
6
  import {
7
7
  scanDocs
8
8
  } from "./chunk-CTL4YL7C.js";
@@ -282,7 +282,7 @@ ${hasInternal ? `
282
282
  `);
283
283
  const secretMode = options.secretMode ?? "prompt";
284
284
  if (options.hub) {
285
- const { registerWithHub } = await import("./hub-register-7NF5BP3E.js");
285
+ const { registerWithHub } = await import("./hub-register-QB73GUF2.js");
286
286
  await registerWithHub({
287
287
  projectName: detectProjectName(),
288
288
  hubToken: options.hubToken,
@@ -298,7 +298,7 @@ ${hasInternal ? `
298
298
  });
299
299
  rl.close();
300
300
  if (answer.toLowerCase() === "y") {
301
- const { registerWithHub } = await import("./hub-register-7NF5BP3E.js");
301
+ const { registerWithHub } = await import("./hub-register-QB73GUF2.js");
302
302
  await registerWithHub({
303
303
  projectName: detectProjectName(),
304
304
  hubToken: options.hubToken,
@@ -42,28 +42,44 @@ var OpenAPIConfigSchema = z.object({
42
42
  basePath: z.string().default("/api"),
43
43
  generatePages: z.boolean().default(true)
44
44
  }).optional();
45
+ var HubProjectSourceSchema = z.discriminatedUnion("kind", [
46
+ z.object({ kind: z.literal("none") }),
47
+ z.object({
48
+ kind: z.literal("git"),
49
+ repo: z.string(),
50
+ ref: z.string().optional(),
51
+ path: z.string().optional(),
52
+ sparse: z.boolean().optional()
53
+ }),
54
+ z.object({ kind: z.literal("url"), url: z.string() }),
55
+ z.object({ kind: z.literal("inline"), markdown: z.string() })
56
+ ]);
57
+ var HubProjectPlacementSchema = z.discriminatedUnion("kind", [
58
+ z.object({ kind: z.literal("card"), href: z.string() }),
59
+ z.object({
60
+ kind: z.literal("tab"),
61
+ sections: z.union([z.literal("all"), z.literal("public"), z.array(z.string())]).optional()
62
+ }),
63
+ z.object({
64
+ kind: z.literal("nested"),
65
+ into: z.string(),
66
+ docsPath: z.string().optional(),
67
+ group: z.string().optional()
68
+ })
69
+ ]);
45
70
  var HubProjectSchema = z.object({
46
71
  name: z.string(),
47
72
  description: z.string(),
48
- href: z.string().optional(),
49
73
  repo: z.string().optional(),
50
74
  status: z.enum(["active", "beta", "planned", "deprecated"]).default("active"),
51
75
  icon: z.string().optional(),
52
76
  tags: z.array(z.string()).optional(),
53
77
  group: z.string().optional(),
54
- mode: z.enum(["link", "embed", "inject"]).optional(),
55
- git: RemoteGitSourceSchema.optional(),
56
- embedSections: z.union([
57
- z.literal("all"),
58
- z.literal("public"),
59
- z.array(z.string())
60
- ]).optional(),
61
- injectInto: z.string().optional(),
62
- docsPath: z.string().optional()
78
+ source: HubProjectSourceSchema,
79
+ placement: HubProjectPlacementSchema
63
80
  });
64
81
  var HubProjectPartialSchema = z.object({
65
82
  description: z.string(),
66
- href: z.string().optional(),
67
83
  repo: z.string().optional(),
68
84
  status: z.enum(["active", "beta", "planned", "deprecated"]).default("active"),
69
85
  icon: z.string().optional(),
@@ -335,8 +351,14 @@ function buildStagingOverlay(stagingDir, originalDir, injections) {
335
351
  symlinkSync(sourcePath, destPath);
336
352
  }
337
353
  }
354
+ function isReservedSource(source) {
355
+ return source.kind === "url" || source.kind === "inline";
356
+ }
338
357
  async function scanHubProjects(config, root) {
339
- if (!config.hub?.scan && !config.hub?.projects?.some((p) => p.mode === "embed" || p.mode === "inject")) {
358
+ const hasGitTabOrNested = config.hub?.projects?.some(
359
+ (p) => p.source.kind === "git" && (p.placement.kind === "tab" || p.placement.kind === "nested")
360
+ ) ?? false;
361
+ if (!config.hub?.scan && !hasGitTabOrNested) {
340
362
  return config;
341
363
  }
342
364
  const scannedProjects = [];
@@ -350,15 +372,17 @@ async function scanHubProjects(config, root) {
350
372
  if (!childConfig.hubProject) continue;
351
373
  const name = childConfig.name ?? basename(dirname(configPath));
352
374
  const hp = childConfig.hubProject;
375
+ const href = childConfig.siteUrl;
353
376
  scannedProjects.push({
354
377
  name,
355
378
  description: hp.description,
356
- href: hp.href ?? childConfig.siteUrl,
357
379
  repo: hp.repo,
358
380
  status: hp.status,
359
381
  icon: hp.icon,
360
382
  tags: hp.tags,
361
- group: hp.group
383
+ group: hp.group,
384
+ source: { kind: "none" },
385
+ placement: { kind: "card", href: href ?? "#" }
362
386
  });
363
387
  } catch {
364
388
  }
@@ -369,18 +393,26 @@ async function scanHubProjects(config, root) {
369
393
  ...scannedProjects.filter((p) => !manualNames.has(p.name)),
370
394
  ...config.hub?.projects ?? []
371
395
  ];
396
+ for (const project of merged) {
397
+ if (isReservedSource(project.source)) {
398
+ throw new Error(
399
+ `Hub project "${project.name}": source.kind "${project.source.kind}" is reserved and not implemented. Use source.kind "none" or "git".`
400
+ );
401
+ }
402
+ }
372
403
  const embedSections = [];
373
404
  for (const project of merged) {
374
- if (project.mode !== "embed" || !project.git) continue;
405
+ if (project.source.kind !== "git" || project.placement.kind !== "tab") continue;
375
406
  const cacheDir = config.hub?.cacheDir ? resolve(root, config.hub.cacheDir) : resolve(root, "node_modules/.cache/clearify-remote");
376
407
  try {
377
408
  const { resolveRemoteSource } = await import("./core/remote.js");
378
- const { localPath } = await resolveRemoteSource(project.git, cacheDir);
409
+ const { kind: _kind, ...gitSource } = project.source;
410
+ const { localPath } = await resolveRemoteSource(gitSource, cacheDir);
379
411
  const remoteConfig = await loadUserConfig(localPath);
380
412
  const remoteSections = remoteConfig.sections ?? [
381
413
  { label: remoteConfig.name ?? project.name, docsDir: remoteConfig.docsDir ?? "./docs" }
382
414
  ];
383
- const filter = project.embedSections ?? "public";
415
+ const filter = project.placement.sections ?? "public";
384
416
  const filtered = remoteSections.filter((s) => {
385
417
  if (filter === "all") return true;
386
418
  if (filter === "public") return !s.draft;
@@ -400,46 +432,44 @@ async function scanHubProjects(config, root) {
400
432
  exclude: s.exclude
401
433
  });
402
434
  }
403
- console.log(` Hub embed "${project.name}": ${filtered.length} sections pulled`);
435
+ console.log(` Hub tab "${project.name}": ${filtered.length} sections pulled`);
404
436
  } catch (err) {
405
437
  console.warn(
406
- ` \u26A0 Hub embed "${project.name}" failed:`,
438
+ ` Hub tab "${project.name}" failed:`,
407
439
  err instanceof Error ? err.message : err
408
440
  );
409
441
  }
410
442
  }
411
443
  const injectionsBySection = /* @__PURE__ */ new Map();
412
444
  for (const project of merged) {
413
- if (project.mode !== "inject" || !project.git) continue;
445
+ if (project.source.kind !== "git" || project.placement.kind !== "nested") continue;
414
446
  const cacheDir = config.hub?.cacheDir ? resolve(root, config.hub.cacheDir) : resolve(root, "node_modules/.cache/clearify-remote");
415
447
  try {
416
448
  const { resolveRemoteSource } = await import("./core/remote.js");
417
- const { localPath, cached } = await resolveRemoteSource(project.git, cacheDir);
418
- const docsPath = project.docsPath ?? "docs";
449
+ const { kind: _kind, ...gitSource } = project.source;
450
+ const { localPath, cached } = await resolveRemoteSource(gitSource, cacheDir);
451
+ const docsPath = project.placement.docsPath ?? "docs";
419
452
  const absoluteDocsPath = resolve(localPath, docsPath);
420
453
  if (!existsSync(absoluteDocsPath)) {
421
454
  console.warn(
422
- ` \u26A0 Hub inject "${project.name}": docs path "${docsPath}" not found in cloned repo.`
455
+ ` Hub nested "${project.name}": docs path "${docsPath}" not found in cloned repo.`
423
456
  );
424
457
  continue;
425
458
  }
426
- const targetLabel = project.injectInto;
427
- if (!targetLabel) {
428
- console.warn(` \u26A0 Hub inject "${project.name}": missing injectInto field.`);
429
- continue;
430
- }
459
+ const targetLabel = project.placement.into;
431
460
  if (!injectionsBySection.has(targetLabel)) {
432
461
  injectionsBySection.set(targetLabel, /* @__PURE__ */ new Map());
433
462
  }
434
463
  const slugifiedName = slugify(project.name);
435
- const relPath = project.group ? `projects/${project.group}/${slugifiedName}` : `projects/${slugifiedName}`;
464
+ const groupSeg = project.placement.group;
465
+ const relPath = groupSeg ? `projects/${groupSeg}/${slugifiedName}` : `projects/${slugifiedName}`;
436
466
  injectionsBySection.get(targetLabel).set(relPath, absoluteDocsPath);
437
467
  console.log(
438
- ` Hub inject "${project.name}": ${cached ? "cached" : "cloned"} \u2192 ${relPath}`
468
+ ` Hub nested "${project.name}": ${cached ? "cached" : "cloned"} to ${relPath}`
439
469
  );
440
470
  } catch (err) {
441
471
  console.warn(
442
- ` \u26A0 Hub inject "${project.name}" failed:`,
472
+ ` Hub nested "${project.name}" failed:`,
443
473
  err instanceof Error ? err.message : err
444
474
  );
445
475
  }
@@ -10,7 +10,7 @@ import {
10
10
  resolveSections,
11
11
  scanHubProjects,
12
12
  writeDataConfig
13
- } from "./chunk-2WML2D77.js";
13
+ } from "./chunk-PGTNVDNF.js";
14
14
  import {
15
15
  buildSectionData
16
16
  } from "./chunk-CTL4YL7C.js";
@@ -37,7 +37,6 @@ declare const ClearifyDataSchema: z.ZodObject<{
37
37
  projects: z.ZodDefault<z.ZodArray<z.ZodObject<{
38
38
  name: z.ZodString;
39
39
  description: z.ZodString;
40
- href: z.ZodOptional<z.ZodString>;
41
40
  repo: z.ZodOptional<z.ZodString>;
42
41
  status: z.ZodDefault<z.ZodEnum<{
43
42
  active: "active";
@@ -48,20 +47,33 @@ declare const ClearifyDataSchema: z.ZodObject<{
48
47
  icon: z.ZodOptional<z.ZodString>;
49
48
  tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
50
49
  group: z.ZodOptional<z.ZodString>;
51
- mode: z.ZodOptional<z.ZodEnum<{
52
- link: "link";
53
- embed: "embed";
54
- inject: "inject";
55
- }>>;
56
- git: z.ZodOptional<z.ZodObject<{
50
+ source: z.ZodDiscriminatedUnion<[z.ZodObject<{
51
+ kind: z.ZodLiteral<"none">;
52
+ }, z.core.$strip>, z.ZodObject<{
53
+ kind: z.ZodLiteral<"git">;
57
54
  repo: z.ZodString;
58
55
  ref: z.ZodOptional<z.ZodString>;
59
56
  path: z.ZodOptional<z.ZodString>;
60
57
  sparse: z.ZodOptional<z.ZodBoolean>;
61
- }, z.core.$strip>>;
62
- embedSections: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<"all">, z.ZodLiteral<"public">, z.ZodArray<z.ZodString>]>>;
63
- injectInto: z.ZodOptional<z.ZodString>;
64
- docsPath: z.ZodOptional<z.ZodString>;
58
+ }, z.core.$strip>, z.ZodObject<{
59
+ kind: z.ZodLiteral<"url">;
60
+ url: z.ZodString;
61
+ }, z.core.$strip>, z.ZodObject<{
62
+ kind: z.ZodLiteral<"inline">;
63
+ markdown: z.ZodString;
64
+ }, z.core.$strip>], "kind">;
65
+ placement: z.ZodDiscriminatedUnion<[z.ZodObject<{
66
+ kind: z.ZodLiteral<"card">;
67
+ href: z.ZodString;
68
+ }, z.core.$strip>, z.ZodObject<{
69
+ kind: z.ZodLiteral<"tab">;
70
+ sections: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<"all">, z.ZodLiteral<"public">, z.ZodArray<z.ZodString>]>>;
71
+ }, z.core.$strip>, z.ZodObject<{
72
+ kind: z.ZodLiteral<"nested">;
73
+ into: z.ZodString;
74
+ docsPath: z.ZodOptional<z.ZodString>;
75
+ group: z.ZodOptional<z.ZodString>;
76
+ }, z.core.$strip>], "kind">;
65
77
  }, z.core.$strip>>>;
66
78
  scan: z.ZodOptional<z.ZodString>;
67
79
  cacheDir: z.ZodOptional<z.ZodString>;
@@ -8,7 +8,7 @@ import {
8
8
  resolveSections,
9
9
  scanHubProjects,
10
10
  writeDataConfig
11
- } from "../chunk-2WML2D77.js";
11
+ } from "../chunk-PGTNVDNF.js";
12
12
  import "../chunk-AJBCHKBP.js";
13
13
  import {
14
14
  defineConfig
@@ -79,6 +79,7 @@ async function secretExists(token, owner, repo, name) {
79
79
  const res = await ghApiFetch(url, { headers: { Authorization: `token ${token}` } });
80
80
  return res.status === 200;
81
81
  }
82
+ var __internal = { encryptSecret };
82
83
  async function promptSecretMode() {
83
84
  const { createInterface } = await import("readline");
84
85
  const rl = createInterface({ input: process.stdin, output: process.stdout });
@@ -212,12 +213,13 @@ async function updateHubRegistry(token, hub, project) {
212
213
  name: project.name,
213
214
  description: project.description,
214
215
  status: "active",
215
- mode: "embed",
216
- git: {
216
+ source: {
217
+ kind: "git",
217
218
  repo: project.gitRepoUrl,
218
219
  ref: project.gitRef,
219
220
  path: project.docsPath
220
- }
221
+ },
222
+ placement: { kind: "tab" }
221
223
  };
222
224
  if (existingIdx >= 0) {
223
225
  currentData.hub.projects[existingIdx] = entry;
@@ -396,5 +398,6 @@ function printSuccessFooter(currentRepo) {
396
398
  `);
397
399
  }
398
400
  export {
401
+ __internal,
399
402
  registerWithHub
400
403
  };
@@ -1,11 +1,11 @@
1
1
  import {
2
2
  buildSite
3
- } from "../chunk-XR4WG237.js";
3
+ } from "../chunk-BCEGDIR6.js";
4
4
  import "../chunk-PRTER35L.js";
5
5
  import "../chunk-SCZZB7OE.js";
6
- import "../chunk-GWYMXKDL.js";
6
+ import "../chunk-RW4IKHY4.js";
7
7
  import "../chunk-B2Q23JW3.js";
8
- import "../chunk-2WML2D77.js";
8
+ import "../chunk-PGTNVDNF.js";
9
9
  import "../chunk-AJBCHKBP.js";
10
10
  import "../chunk-V7LLYIRO.js";
11
11
  import "../chunk-CTL4YL7C.js";
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  checkFrontmatter,
3
3
  checkLinks
4
- } from "../chunk-HZRIVY25.js";
5
- import "../chunk-2WML2D77.js";
4
+ } from "../chunk-CK2SLBAG.js";
5
+ import "../chunk-PGTNVDNF.js";
6
6
  import "../chunk-AJBCHKBP.js";
7
7
  import "../chunk-V7LLYIRO.js";
8
8
  import "../chunk-CTL4YL7C.js";
@@ -1,25 +1,25 @@
1
1
  import {
2
2
  buildSite
3
- } from "../chunk-XR4WG237.js";
3
+ } from "../chunk-BCEGDIR6.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-GWYMXKDL.js";
10
+ } from "../chunk-RW4IKHY4.js";
11
11
  import "../chunk-B2Q23JW3.js";
12
12
  import {
13
13
  init
14
- } from "../chunk-JZZ2BNIL.js";
14
+ } from "../chunk-GPBEDIOY.js";
15
15
  import {
16
16
  checkFrontmatter,
17
17
  checkLinks
18
- } from "../chunk-HZRIVY25.js";
18
+ } from "../chunk-CK2SLBAG.js";
19
19
  import {
20
20
  loadUserConfig,
21
21
  resolveConfig
22
- } from "../chunk-2WML2D77.js";
22
+ } from "../chunk-PGTNVDNF.js";
23
23
  import "../chunk-AJBCHKBP.js";
24
24
  import "../chunk-V7LLYIRO.js";
25
25
  import "../chunk-CTL4YL7C.js";
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  init
3
- } from "../chunk-JZZ2BNIL.js";
3
+ } from "../chunk-GPBEDIOY.js";
4
4
  export {
5
5
  init
6
6
  };
@@ -58,7 +58,7 @@ interface ClearifyConfig {
58
58
  [key: string]: string | undefined;
59
59
  };
60
60
  hub?: HubConfig;
61
- hubProject?: Omit<HubProject, 'name'>;
61
+ hubProject?: HubProjectPartial;
62
62
  customCss?: string;
63
63
  headTags?: string[];
64
64
  includeReadme?: boolean;
@@ -92,10 +92,54 @@ interface RouteEntry {
92
92
  componentPath?: string;
93
93
  redirectTo?: string;
94
94
  }
95
+ /**
96
+ * Where a hub project's content comes from.
97
+ *
98
+ * `none`: no content pulled, card links out only.
99
+ * `git`: sparse-clone a repo subdirectory.
100
+ * `url`: reserved for future remote-docs fetch, not implemented.
101
+ * `inline`: reserved for future inline markdown, not implemented.
102
+ */
103
+ type HubProjectSource = {
104
+ kind: 'none';
105
+ } | {
106
+ kind: 'git';
107
+ repo: string;
108
+ /** branch, tag, or SHA. Default: 'main' */
109
+ ref?: string;
110
+ /** subdirectory to sparse-checkout. Default: 'docs/public' */
111
+ path?: string;
112
+ /** defaults to true when path is set */
113
+ sparse?: boolean;
114
+ } | {
115
+ kind: 'url';
116
+ url: string;
117
+ } | {
118
+ kind: 'inline';
119
+ markdown: string;
120
+ };
121
+ /**
122
+ * How a hub project shows up in the hub UI.
123
+ *
124
+ * `card`: grid entry that links to an external href.
125
+ * `tab`: full tab in the hub nav, rendering the cloned sections.
126
+ * `nested`: folded into an existing hub section as a subfolder.
127
+ */
128
+ type HubProjectPlacement = {
129
+ kind: 'card';
130
+ href: string;
131
+ } | {
132
+ kind: 'tab';
133
+ sections?: 'all' | 'public' | string[];
134
+ } | {
135
+ kind: 'nested';
136
+ into: string;
137
+ docsPath?: string;
138
+ group?: string;
139
+ };
95
140
  interface HubProject {
96
141
  name: string;
97
142
  description: string;
98
- href?: string;
99
143
  repo?: string;
100
144
  status?: 'active' | 'beta' | 'planned' | 'deprecated';
101
145
  icon?: string;
@@ -103,11 +147,25 @@ interface HubProject {
103
147
  group?: string;
104
148
  hubUrl?: string;
105
149
  hubName?: string;
106
- mode?: 'link' | 'embed' | 'inject';
107
- git?: RemoteGitSource;
108
- embedSections?: 'all' | 'public' | string[];
109
- injectInto?: string;
110
- docsPath?: string;
150
+ source: HubProjectSource;
151
+ placement: HubProjectPlacement;
152
+ }
153
+ /**
154
+ * The subset of HubProject fields a sub-project can declare about itself in
155
+ * `clearify.config.ts`. The hub scanner composes this with the sub-project's
156
+ * `siteUrl` and assigns `source` + `placement` on its own side.
157
+ */
158
+ interface HubProjectPartial {
159
+ description: string;
160
+ repo?: string;
161
+ status?: 'active' | 'beta' | 'planned' | 'deprecated';
162
+ icon?: string;
163
+ tags?: string[];
164
+ group?: string;
165
+ /** URL of the parent hub site, used by the Sidebar back-link. */
166
+ hubUrl?: string;
167
+ /** Display name for the Sidebar back-link. Default: 'Hub'. */
168
+ hubName?: string;
111
169
  }
112
170
  interface HubConfig {
113
171
  projects: HubProject[];
@@ -135,4 +193,4 @@ interface ClearifyDataConfig {
135
193
  }
136
194
  declare function defineConfig(config: Partial<ClearifyConfig>): Partial<ClearifyConfig>;
137
195
 
138
- export { type ClearifyConfig, type ClearifyDataConfig, type DocCategory, type HubConfig, type HubProject, type NavigationItem, type PageFrontmatter, type RemoteGitSource, type ResolvedSection, type RouteEntry, type SectionConfig, type SectionNavigation, defineConfig };
196
+ export { type ClearifyConfig, type ClearifyDataConfig, type DocCategory, type HubConfig, type HubProject, type HubProjectPartial, type HubProjectPlacement, type HubProjectSource, type NavigationItem, type PageFrontmatter, type RemoteGitSource, type ResolvedSection, type RouteEntry, type SectionConfig, type SectionNavigation, defineConfig };
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  clearifyPlugin
3
- } from "../chunk-GWYMXKDL.js";
3
+ } from "../chunk-RW4IKHY4.js";
4
4
  import "../chunk-B2Q23JW3.js";
5
- import "../chunk-2WML2D77.js";
5
+ import "../chunk-PGTNVDNF.js";
6
6
  import "../chunk-AJBCHKBP.js";
7
7
  import "../chunk-V7LLYIRO.js";
8
8
  import "../chunk-CTL4YL7C.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marlinjai/clearify",
3
- "version": "1.20.0",
3
+ "version": "2.0.0",
4
4
  "description": "An open-source documentation site generator. Turn markdown into beautiful docs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,7 +28,8 @@
28
28
  "postbuild": "node scripts/generate-schema.mjs",
29
29
  "dev:self": "node bin/clearify.js dev",
30
30
  "prepare": "husky",
31
- "release": "semantic-release"
31
+ "release": "semantic-release",
32
+ "test": "vitest run"
32
33
  },
33
34
  "dependencies": {
34
35
  "@inquirer/password": "^5.0.11",
@@ -82,7 +83,8 @@
82
83
  "puppeteer": "^24.37.2",
83
84
  "semantic-release": "^25.0.3",
84
85
  "tsup": "^8.3.6",
85
- "typescript": "^5.7.3"
86
+ "typescript": "^5.7.3",
87
+ "vitest": "^4.1.4"
86
88
  },
87
89
  "packageManager": "pnpm@9.15.0",
88
90
  "license": "MIT",
@@ -1,13 +1,13 @@
1
1
  import React, { useState, useEffect, useRef } from 'react';
2
2
  import { Link, useLocation, useNavigate } from 'react-router-dom';
3
3
  import clsx from 'clsx';
4
- import type { NavigationItem, SectionNavigation, HubProject } from '../types/index.js';
4
+ import type { NavigationItem, SectionNavigation, HubProjectPartial } from '../types/index.js';
5
5
 
6
6
  interface SidebarProps {
7
7
  sections: SectionNavigation[];
8
8
  open: boolean;
9
9
  onClose: () => void;
10
- hubProject?: Omit<HubProject, 'name'>;
10
+ hubProject?: HubProjectPartial;
11
11
  }
12
12
 
13
13
  function findActiveSection(pathname: string, sections: SectionNavigation[]): string {
@@ -1,5 +1,5 @@
1
1
  import React, { useEffect, useState, useCallback } from 'react';
2
- import type { HubProject } from '../../types/index.js';
2
+ import type { HubProject, HubProjectSource, HubProjectPlacement } from '../../types/index.js';
3
3
  import { Modal } from './components/Modal.js';
4
4
  import { ConfirmDialog } from './components/ConfirmDialog.js';
5
5
  import { useToast, ToastContainer } from './components/Toast.js';
@@ -16,23 +16,141 @@ const statusColors: Record<string, { bg: string; text: string }> = {
16
16
  };
17
17
 
18
18
  /* ------------------------------------------------------------------ */
19
- /* Empty default */
19
+ /* Draft form shape */
20
20
  /* ------------------------------------------------------------------ */
21
- function emptyProject(): HubProject {
21
+ /**
22
+ * The form state carries every field for every kind so the user can flip
23
+ * between kinds without losing input. We clean it to the discriminated-
24
+ * union shape before saving.
25
+ */
26
+ interface DraftProject {
27
+ name: string;
28
+ description: string;
29
+ status: 'active' | 'beta' | 'planned' | 'deprecated';
30
+ icon?: string;
31
+ group?: string;
32
+ tags?: string[];
33
+ repo?: string;
34
+ sourceKind: HubProjectSource['kind'];
35
+ gitRepo: string;
36
+ gitRef: string;
37
+ gitPath: string;
38
+ urlUrl: string;
39
+ inlineMarkdown: string;
40
+ placementKind: HubProjectPlacement['kind'];
41
+ cardHref: string;
42
+ tabSections: 'all' | 'public';
43
+ nestedInto: string;
44
+ nestedDocsPath: string;
45
+ nestedGroup: string;
46
+ }
47
+
48
+ function emptyDraft(): DraftProject {
22
49
  return {
23
50
  name: '',
24
51
  description: '',
25
- mode: 'link',
26
52
  status: 'active',
27
53
  icon: '',
28
54
  group: '',
29
- href: '',
30
55
  tags: [],
31
- git: { repo: '', ref: '' },
32
- embedSections: 'all',
33
- injectInto: '',
34
- docsPath: 'docs',
56
+ repo: '',
57
+ sourceKind: 'none',
58
+ gitRepo: '',
59
+ gitRef: 'main',
60
+ gitPath: 'docs/public',
61
+ urlUrl: '',
62
+ inlineMarkdown: '',
63
+ placementKind: 'card',
64
+ cardHref: '',
65
+ tabSections: 'public',
66
+ nestedInto: '',
67
+ nestedDocsPath: 'docs',
68
+ nestedGroup: '',
69
+ };
70
+ }
71
+
72
+ function projectToDraft(p: HubProject): DraftProject {
73
+ const d = emptyDraft();
74
+ d.name = p.name;
75
+ d.description = p.description;
76
+ d.status = p.status ?? 'active';
77
+ d.icon = p.icon ?? '';
78
+ d.group = p.group ?? '';
79
+ d.tags = p.tags ? [...p.tags] : [];
80
+ d.repo = p.repo ?? '';
81
+
82
+ d.sourceKind = p.source.kind;
83
+ if (p.source.kind === 'git') {
84
+ d.gitRepo = p.source.repo;
85
+ d.gitRef = p.source.ref ?? 'main';
86
+ d.gitPath = p.source.path ?? 'docs/public';
87
+ } else if (p.source.kind === 'url') {
88
+ d.urlUrl = p.source.url;
89
+ } else if (p.source.kind === 'inline') {
90
+ d.inlineMarkdown = p.source.markdown;
91
+ }
92
+
93
+ d.placementKind = p.placement.kind;
94
+ if (p.placement.kind === 'card') {
95
+ d.cardHref = p.placement.href;
96
+ } else if (p.placement.kind === 'tab') {
97
+ const s = p.placement.sections;
98
+ d.tabSections = s === 'all' ? 'all' : 'public';
99
+ } else if (p.placement.kind === 'nested') {
100
+ d.nestedInto = p.placement.into;
101
+ d.nestedDocsPath = p.placement.docsPath ?? 'docs';
102
+ d.nestedGroup = p.placement.group ?? '';
103
+ }
104
+ return d;
105
+ }
106
+
107
+ function draftToProject(d: DraftProject): HubProject {
108
+ let source: HubProjectSource;
109
+ if (d.sourceKind === 'git') {
110
+ const g: Extract<HubProjectSource, { kind: 'git' }> = {
111
+ kind: 'git',
112
+ repo: d.gitRepo,
113
+ };
114
+ if (d.gitRef && d.gitRef !== 'main') g.ref = d.gitRef;
115
+ if (d.gitPath) g.path = d.gitPath;
116
+ source = g;
117
+ } else if (d.sourceKind === 'url') {
118
+ source = { kind: 'url', url: d.urlUrl };
119
+ } else if (d.sourceKind === 'inline') {
120
+ source = { kind: 'inline', markdown: d.inlineMarkdown };
121
+ } else {
122
+ source = { kind: 'none' };
123
+ }
124
+
125
+ let placement: HubProjectPlacement;
126
+ if (d.placementKind === 'tab') {
127
+ const t: Extract<HubProjectPlacement, { kind: 'tab' }> = { kind: 'tab' };
128
+ if (d.tabSections && d.tabSections !== 'public') t.sections = d.tabSections;
129
+ placement = t;
130
+ } else if (d.placementKind === 'nested') {
131
+ const n: Extract<HubProjectPlacement, { kind: 'nested' }> = {
132
+ kind: 'nested',
133
+ into: d.nestedInto,
134
+ };
135
+ if (d.nestedDocsPath && d.nestedDocsPath !== 'docs') n.docsPath = d.nestedDocsPath;
136
+ if (d.nestedGroup) n.group = d.nestedGroup;
137
+ placement = n;
138
+ } else {
139
+ placement = { kind: 'card', href: d.cardHref };
140
+ }
141
+
142
+ const project: HubProject = {
143
+ name: d.name.trim(),
144
+ description: d.description.trim(),
145
+ source,
146
+ placement,
35
147
  };
148
+ if (d.status && d.status !== 'active') project.status = d.status;
149
+ if (d.icon) project.icon = d.icon;
150
+ if (d.group) project.group = d.group;
151
+ if (d.tags && d.tags.length > 0) project.tags = d.tags;
152
+ if (d.repo) project.repo = d.repo;
153
+ return project;
36
154
  }
37
155
 
38
156
  /* ------------------------------------------------------------------ */
@@ -70,144 +188,138 @@ const primaryBtnStyle: React.CSSProperties = {
70
188
  };
71
189
 
72
190
  /* ------------------------------------------------------------------ */
73
- /* Clean project for saving */
191
+ /* Summary of the source+placement combo for the list view */
74
192
  /* ------------------------------------------------------------------ */
75
- function cleanProject(draft: HubProject, original?: HubProject): HubProject {
76
- const cleaned: HubProject = { ...draft };
77
- const mode = cleaned.mode ?? 'link';
78
- if (mode === 'link') {
79
- delete cleaned.git;
80
- delete cleaned.embedSections;
81
- delete cleaned.injectInto;
82
- delete cleaned.docsPath;
83
- // Don't add mode: 'link' if original didn't have it (it's the default)
84
- if (!original?.mode) delete cleaned.mode;
85
- } else if (mode === 'embed') {
86
- delete cleaned.href;
87
- delete cleaned.injectInto;
88
- delete cleaned.docsPath;
89
- } else if (mode === 'inject') {
90
- delete cleaned.href;
91
- delete cleaned.embedSections;
92
- }
93
- if (!cleaned.icon) delete cleaned.icon;
94
- if (!cleaned.group) delete cleaned.group;
95
- if (!cleaned.href) delete cleaned.href;
96
- if (cleaned.tags && cleaned.tags.length === 0) delete cleaned.tags;
97
- if (cleaned.git && !cleaned.git.repo) delete cleaned.git;
98
- if (cleaned.git && !cleaned.git.ref) delete cleaned.git.ref;
99
- if (cleaned.status === 'active' && !original?.status) delete cleaned.status;
100
-
101
- // Preserve original field ordering: rebuild using original's key order first
102
- if (original) {
103
- const ordered: any = {};
104
- for (const key of Object.keys(original)) {
105
- if (key in cleaned) ordered[key] = (cleaned as any)[key];
106
- }
107
- for (const key of Object.keys(cleaned)) {
108
- if (!(key in ordered)) ordered[key] = (cleaned as any)[key];
109
- }
110
- return ordered as HubProject;
111
- }
112
- return cleaned;
193
+ function combo(project: HubProject): string {
194
+ return `${project.source.kind} + ${project.placement.kind}`;
113
195
  }
114
196
 
115
197
  /* ------------------------------------------------------------------ */
116
198
  /* Project form */
117
199
  /* ------------------------------------------------------------------ */
118
200
  function ProjectForm({
119
- project,
201
+ draft,
120
202
  onChange,
121
203
  onSave,
122
204
  saving,
123
205
  }: {
124
- project: HubProject;
125
- onChange: (p: HubProject) => void;
206
+ draft: DraftProject;
207
+ onChange: (d: DraftProject) => void;
126
208
  onSave: () => void;
127
209
  saving: boolean;
128
210
  }) {
129
- const mode = project.mode ?? 'link';
130
-
131
- const set = <K extends keyof HubProject>(key: K, value: HubProject[K]) =>
132
- onChange({ ...project, [key]: value });
211
+ const set = <K extends keyof DraftProject>(key: K, value: DraftProject[K]) =>
212
+ onChange({ ...draft, [key]: value });
133
213
 
134
214
  return (
135
215
  <div style={{ display: 'flex', flexDirection: 'column', gap: '0.875rem' }}>
136
216
  <div>
137
217
  <label style={labelStyle}>Name *</label>
138
- <input style={inputStyle} value={project.name} onChange={(e) => set('name', e.target.value)} placeholder="my-project" />
218
+ <input style={inputStyle} value={draft.name} onChange={(e) => set('name', e.target.value)} placeholder="my-project" />
139
219
  </div>
140
220
  <div>
141
221
  <label style={labelStyle}>Description *</label>
142
- <textarea style={{ ...inputStyle, minHeight: 72, resize: 'vertical' }} value={project.description} onChange={(e) => set('description', e.target.value)} placeholder="A short project description" />
222
+ <textarea style={{ ...inputStyle, minHeight: 72, resize: 'vertical' }} value={draft.description} onChange={(e) => set('description', e.target.value)} placeholder="A short project description" />
143
223
  </div>
144
224
  <div>
145
225
  <label style={labelStyle}>Icon</label>
146
- <input style={inputStyle} value={project.icon ?? ''} onChange={(e) => set('icon', e.target.value)} placeholder="e.g. book, code, rocket" />
226
+ <input style={inputStyle} value={draft.icon ?? ''} onChange={(e) => set('icon', e.target.value)} placeholder="e.g. book, code, rocket" />
147
227
  </div>
148
228
  <div style={{ display: 'flex', gap: '0.75rem' }}>
149
- <div style={{ flex: 1 }}>
150
- <label style={labelStyle}>Mode</label>
151
- <select style={inputStyle} value={mode} onChange={(e) => set('mode', e.target.value as HubProject['mode'])}>
152
- <option value="link">Link</option>
153
- <option value="embed">Embed</option>
154
- <option value="inject">Inject</option>
155
- </select>
156
- </div>
157
229
  <div style={{ flex: 1 }}>
158
230
  <label style={labelStyle}>Status</label>
159
- <select style={inputStyle} value={project.status ?? 'active'} onChange={(e) => set('status', e.target.value as HubProject['status'])}>
231
+ <select style={inputStyle} value={draft.status} onChange={(e) => set('status', e.target.value as DraftProject['status'])}>
160
232
  <option value="active">Active</option>
161
233
  <option value="beta">Beta</option>
162
234
  <option value="planned">Planned</option>
163
235
  <option value="deprecated">Deprecated</option>
164
236
  </select>
165
237
  </div>
166
- </div>
167
- <div>
168
- <label style={labelStyle}>Group</label>
169
- <input style={inputStyle} value={project.group ?? ''} onChange={(e) => set('group', e.target.value)} placeholder="e.g. backend, frontend" />
238
+ <div style={{ flex: 1 }}>
239
+ <label style={labelStyle}>Group</label>
240
+ <input style={inputStyle} value={draft.group ?? ''} onChange={(e) => set('group', e.target.value)} placeholder="e.g. backend, frontend" />
241
+ </div>
170
242
  </div>
171
243
 
172
- {mode === 'link' && (
173
- <div>
174
- <label style={labelStyle}>URL (href)</label>
175
- <input style={inputStyle} value={project.href ?? ''} onChange={(e) => set('href', e.target.value)} placeholder="https://example.com/docs" />
244
+ <div style={{ display: 'flex', gap: '0.75rem' }}>
245
+ <div style={{ flex: 1 }}>
246
+ <label style={labelStyle}>Source</label>
247
+ <select
248
+ style={inputStyle}
249
+ value={draft.sourceKind}
250
+ onChange={(e) => set('sourceKind', e.target.value as HubProjectSource['kind'])}
251
+ >
252
+ <option value="none">none (no content pulled)</option>
253
+ <option value="git">git (sparse clone)</option>
254
+ <option value="url" disabled>url (reserved)</option>
255
+ <option value="inline" disabled>inline (reserved)</option>
256
+ </select>
176
257
  </div>
177
- )}
258
+ <div style={{ flex: 1 }}>
259
+ <label style={labelStyle}>Placement</label>
260
+ <select
261
+ style={inputStyle}
262
+ value={draft.placementKind}
263
+ onChange={(e) => set('placementKind', e.target.value as HubProjectPlacement['kind'])}
264
+ >
265
+ <option value="card">card (grid entry, link out)</option>
266
+ <option value="tab">tab (full hub tab)</option>
267
+ <option value="nested">nested (into existing section)</option>
268
+ </select>
269
+ </div>
270
+ </div>
178
271
 
179
- {(mode === 'embed' || mode === 'inject') && (
272
+ {draft.sourceKind === 'git' && (
180
273
  <>
181
274
  <div>
182
- <label style={labelStyle}>Git Repo</label>
183
- <input style={inputStyle} value={project.git?.repo ?? ''} onChange={(e) => set('git', { ...project.git, repo: e.target.value })} placeholder="org/repo" />
275
+ <label style={labelStyle}>Git repo *</label>
276
+ <input style={inputStyle} value={draft.gitRepo} onChange={(e) => set('gitRepo', e.target.value)} placeholder="https://github.com/org/repo.git" />
184
277
  </div>
185
- <div>
186
- <label style={labelStyle}>Git Ref</label>
187
- <input style={inputStyle} value={project.git?.ref ?? ''} onChange={(e) => set('git', { ...project.git, repo: project.git?.repo ?? '', ref: e.target.value })} placeholder="main" />
278
+ <div style={{ display: 'flex', gap: '0.75rem' }}>
279
+ <div style={{ flex: 1 }}>
280
+ <label style={labelStyle}>Git ref</label>
281
+ <input style={inputStyle} value={draft.gitRef} onChange={(e) => set('gitRef', e.target.value)} placeholder="main" />
282
+ </div>
283
+ <div style={{ flex: 1 }}>
284
+ <label style={labelStyle}>Git path</label>
285
+ <input style={inputStyle} value={draft.gitPath} onChange={(e) => set('gitPath', e.target.value)} placeholder="docs/public" />
286
+ </div>
188
287
  </div>
189
288
  </>
190
289
  )}
191
290
 
192
- {mode === 'embed' && (
291
+ {draft.placementKind === 'card' && (
193
292
  <div>
194
- <label style={labelStyle}>Embed Sections</label>
195
- <select style={inputStyle} value={typeof project.embedSections === 'string' ? project.embedSections : 'all'} onChange={(e) => set('embedSections', e.target.value as 'all' | 'public')}>
196
- <option value="all">All</option>
197
- <option value="public">Public</option>
293
+ <label style={labelStyle}>Card href *</label>
294
+ <input style={inputStyle} value={draft.cardHref} onChange={(e) => set('cardHref', e.target.value)} placeholder="https://example.com/docs" />
295
+ </div>
296
+ )}
297
+
298
+ {draft.placementKind === 'tab' && (
299
+ <div>
300
+ <label style={labelStyle}>Tab sections</label>
301
+ <select style={inputStyle} value={draft.tabSections} onChange={(e) => set('tabSections', e.target.value as 'all' | 'public')}>
302
+ <option value="public">public (non-draft)</option>
303
+ <option value="all">all</option>
198
304
  </select>
199
305
  </div>
200
306
  )}
201
307
 
202
- {mode === 'inject' && (
308
+ {draft.placementKind === 'nested' && (
203
309
  <>
204
310
  <div>
205
- <label style={labelStyle}>Inject Into</label>
206
- <input style={inputStyle} value={project.injectInto ?? ''} onChange={(e) => set('injectInto', e.target.value)} placeholder="section-id" />
311
+ <label style={labelStyle}>Nest into (section label) *</label>
312
+ <input style={inputStyle} value={draft.nestedInto} onChange={(e) => set('nestedInto', e.target.value)} placeholder="Documentation" />
207
313
  </div>
208
- <div>
209
- <label style={labelStyle}>Docs Path</label>
210
- <input style={inputStyle} value={project.docsPath ?? 'docs'} onChange={(e) => set('docsPath', e.target.value)} placeholder="docs" />
314
+ <div style={{ display: 'flex', gap: '0.75rem' }}>
315
+ <div style={{ flex: 1 }}>
316
+ <label style={labelStyle}>Docs path (in cloned repo)</label>
317
+ <input style={inputStyle} value={draft.nestedDocsPath} onChange={(e) => set('nestedDocsPath', e.target.value)} placeholder="docs" />
318
+ </div>
319
+ <div style={{ flex: 1 }}>
320
+ <label style={labelStyle}>Nested group (optional)</label>
321
+ <input style={inputStyle} value={draft.nestedGroup} onChange={(e) => set('nestedGroup', e.target.value)} placeholder="Services" />
322
+ </div>
211
323
  </div>
212
324
  </>
213
325
  )}
@@ -216,7 +328,7 @@ function ProjectForm({
216
328
  <label style={labelStyle}>Tags (comma-separated)</label>
217
329
  <input
218
330
  style={inputStyle}
219
- value={(project.tags ?? []).join(', ')}
331
+ value={(draft.tags ?? []).join(', ')}
220
332
  onChange={(e) => set('tags', e.target.value.split(',').map((t) => t.trim()).filter(Boolean))}
221
333
  placeholder="api, docs, internal"
222
334
  />
@@ -224,7 +336,7 @@ function ProjectForm({
224
336
 
225
337
  <div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '0.25rem' }}>
226
338
  <button style={primaryBtnStyle} onClick={onSave} disabled={saving}>
227
- {saving ? 'Saving\u2026' : 'Save Project'}
339
+ {saving ? 'Saving...' : 'Save Project'}
228
340
  </button>
229
341
  </div>
230
342
  </div>
@@ -243,7 +355,7 @@ export function ProjectsManager() {
243
355
 
244
356
  // modal state
245
357
  const [editIndex, setEditIndex] = useState<number | null>(null); // null = closed, -1 = new
246
- const [draft, setDraft] = useState<HubProject>(emptyProject());
358
+ const [draft, setDraft] = useState<DraftProject>(emptyDraft());
247
359
  const [saving, setSaving] = useState(false);
248
360
 
249
361
  // delete confirm
@@ -318,9 +430,20 @@ export function ProjectsManager() {
318
430
  show('Name and description are required', 'error');
319
431
  return;
320
432
  }
433
+ if (draft.sourceKind === 'git' && !draft.gitRepo.trim()) {
434
+ show('Git repo is required for git source', 'error');
435
+ return;
436
+ }
437
+ if (draft.placementKind === 'card' && !draft.cardHref.trim()) {
438
+ show('Card href is required for card placement', 'error');
439
+ return;
440
+ }
441
+ if (draft.placementKind === 'nested' && !draft.nestedInto.trim()) {
442
+ show('Nest-into section label is required for nested placement', 'error');
443
+ return;
444
+ }
321
445
 
322
- const original = editIndex !== null && editIndex >= 0 ? projects[editIndex] : undefined;
323
- const cleaned = cleanProject(draft, original);
446
+ const cleaned = draftToProject(draft);
324
447
  const next = [...projects];
325
448
  if (editIndex === -1) {
326
449
  next.push(cleaned);
@@ -360,18 +483,13 @@ export function ProjectsManager() {
360
483
 
361
484
  /* ---------- open modal ---------- */
362
485
  const openAdd = () => {
363
- setDraft(emptyProject());
486
+ setDraft(emptyDraft());
364
487
  setEditIndex(-1);
365
488
  };
366
489
 
367
490
  const openEdit = (index: number) => {
368
491
  const p = projects[index];
369
- setDraft({
370
- ...emptyProject(),
371
- ...p,
372
- git: p.git ? { ...p.git } : { repo: '', ref: '' },
373
- tags: p.tags ? [...p.tags] : [],
374
- });
492
+ setDraft(projectToDraft(p));
375
493
  setEditIndex(index);
376
494
  };
377
495
 
@@ -415,7 +533,7 @@ export function ProjectsManager() {
415
533
  <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
416
534
  <thead>
417
535
  <tr style={{ backgroundColor: 'var(--clearify-bg-secondary)', borderBottom: '1px solid var(--clearify-border)' }}>
418
- {['Name', 'Description', 'Mode', 'Group', 'Status', ''].map((h) => (
536
+ {['Name', 'Description', 'Source + Placement', 'Group', 'Status', ''].map((h) => (
419
537
  <th key={h} style={{ textAlign: 'left', padding: '0.625rem 0.75rem', fontWeight: 600, color: 'var(--clearify-text-secondary)', fontSize: '0.8125rem' }}>{h}</th>
420
538
  ))}
421
539
  </tr>
@@ -438,8 +556,8 @@ export function ProjectsManager() {
438
556
  <td style={{ padding: '0.625rem 0.75rem', color: 'var(--clearify-text-secondary)', maxWidth: 250, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
439
557
  {p.description}
440
558
  </td>
441
- <td style={{ padding: '0.625rem 0.75rem', color: 'var(--clearify-text-secondary)' }}>{p.mode ?? 'link'}</td>
442
- <td style={{ padding: '0.625rem 0.75rem', color: 'var(--clearify-text-secondary)' }}>{p.group ?? '\u2014'}</td>
559
+ <td style={{ padding: '0.625rem 0.75rem', color: 'var(--clearify-text-secondary)' }}>{combo(p)}</td>
560
+ <td style={{ padding: '0.625rem 0.75rem', color: 'var(--clearify-text-secondary)' }}>{p.group ?? '-'}</td>
443
561
  <td style={{ padding: '0.625rem 0.75rem' }}>
444
562
  <span style={{ display: 'inline-block', padding: '0.125rem 0.5rem', borderRadius: '9999px', fontSize: '0.75rem', fontWeight: 600, backgroundColor: colors.bg, color: colors.text }}>
445
563
  {st}
@@ -512,7 +630,7 @@ export function ProjectsManager() {
512
630
 
513
631
  {/* Edit / Add modal */}
514
632
  <Modal open={editIndex !== null} onClose={() => setEditIndex(null)} title={editIndex === -1 ? 'Add Project' : `Edit: ${editIndex !== null && editIndex >= 0 ? projects[editIndex]?.name ?? '' : ''}`}>
515
- <ProjectForm project={draft} onChange={setDraft} onSave={handleSave} saving={saving} />
633
+ <ProjectForm draft={draft} onChange={setDraft} onSave={handleSave} saving={saving} />
516
634
  </Modal>
517
635
 
518
636
  {/* Delete confirmation */}
@@ -8,6 +8,22 @@ interface ProjectGridProps {
8
8
  children?: React.ReactNode;
9
9
  }
10
10
 
11
+ function projectHref(project: HubProject): string | undefined {
12
+ if (project.placement.kind === 'card') return project.placement.href;
13
+ if (project.placement.kind === 'tab') {
14
+ // Default to the project's own tab path on the hub
15
+ const slug = project.name
16
+ .toLowerCase()
17
+ .replace(/[^a-z0-9]+/g, '-')
18
+ .replace(/^-|-$/g, '');
19
+ return `/${slug}`;
20
+ }
21
+ if (project.placement.kind === 'nested') {
22
+ return `/${project.placement.into}`;
23
+ }
24
+ return undefined;
25
+ }
26
+
11
27
  function ProjectGridInner({ cols, projects }: { cols: number; projects: HubProject[] }) {
12
28
  return (
13
29
  <div
@@ -23,7 +39,7 @@ function ProjectGridInner({ cols, projects }: { cols: number; projects: HubProje
23
39
  key={project.name}
24
40
  name={project.name}
25
41
  description={project.description}
26
- href={project.href}
42
+ href={projectHref(project)}
27
43
  repo={project.repo}
28
44
  status={project.status}
29
45
  icon={project.icon}