@marlinjai/clearify 1.19.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.
- package/dist/config-schema.json +142 -44
- package/dist/node/{chunk-XR4WG237.js → chunk-BCEGDIR6.js} +2 -2
- package/dist/node/{chunk-HZRIVY25.js → chunk-CK2SLBAG.js} +1 -1
- package/dist/node/{chunk-HVHCUAVU.js → chunk-GPBEDIOY.js} +2 -2
- package/dist/node/{chunk-2WML2D77.js → chunk-PGTNVDNF.js} +61 -31
- package/dist/node/{chunk-GWYMXKDL.js → chunk-RW4IKHY4.js} +1 -1
- package/dist/node/core/config.d.ts +23 -11
- package/dist/node/core/config.js +1 -1
- package/dist/node/{hub-register-M7C3G3FE.js → hub-register-QB73GUF2.js} +23 -8
- package/dist/node/node/build.js +3 -3
- package/dist/node/node/check.js +2 -2
- package/dist/node/node/index.js +5 -5
- package/dist/node/node/init.js +1 -1
- package/dist/node/types/index.d.ts +66 -8
- package/dist/node/vite-plugin/index.js +2 -2
- package/package.json +5 -3
- package/src/theme/Sidebar.tsx +2 -2
- package/src/theme/admin/ProjectsManager.tsx +227 -109
- package/src/theme/components/ProjectGrid.tsx +17 -1
|
@@ -58,7 +58,7 @@ interface ClearifyConfig {
|
|
|
58
58
|
[key: string]: string | undefined;
|
|
59
59
|
};
|
|
60
60
|
hub?: HubConfig;
|
|
61
|
-
hubProject?:
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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-
|
|
3
|
+
} from "../chunk-RW4IKHY4.js";
|
|
4
4
|
import "../chunk-B2Q23JW3.js";
|
|
5
|
-
import "../chunk-
|
|
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": "
|
|
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",
|
package/src/theme/Sidebar.tsx
CHANGED
|
@@ -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,
|
|
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?:
|
|
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
|
-
/*
|
|
19
|
+
/* Draft form shape */
|
|
20
20
|
/* ------------------------------------------------------------------ */
|
|
21
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
/*
|
|
191
|
+
/* Summary of the source+placement combo for the list view */
|
|
74
192
|
/* ------------------------------------------------------------------ */
|
|
75
|
-
function
|
|
76
|
-
|
|
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
|
-
|
|
201
|
+
draft,
|
|
120
202
|
onChange,
|
|
121
203
|
onSave,
|
|
122
204
|
saving,
|
|
123
205
|
}: {
|
|
124
|
-
|
|
125
|
-
onChange: (
|
|
206
|
+
draft: DraftProject;
|
|
207
|
+
onChange: (d: DraftProject) => void;
|
|
126
208
|
onSave: () => void;
|
|
127
209
|
saving: boolean;
|
|
128
210
|
}) {
|
|
129
|
-
const
|
|
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={
|
|
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={
|
|
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={
|
|
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={
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
{
|
|
173
|
-
<div>
|
|
174
|
-
<label style={labelStyle}>
|
|
175
|
-
<
|
|
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
|
-
{
|
|
272
|
+
{draft.sourceKind === 'git' && (
|
|
180
273
|
<>
|
|
181
274
|
<div>
|
|
182
|
-
<label style={labelStyle}>Git
|
|
183
|
-
<input style={inputStyle} value={
|
|
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
|
-
<
|
|
187
|
-
|
|
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
|
-
{
|
|
291
|
+
{draft.placementKind === 'card' && (
|
|
193
292
|
<div>
|
|
194
|
-
<label style={labelStyle}>
|
|
195
|
-
<
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
{
|
|
308
|
+
{draft.placementKind === 'nested' && (
|
|
203
309
|
<>
|
|
204
310
|
<div>
|
|
205
|
-
<label style={labelStyle}>
|
|
206
|
-
<input style={inputStyle} value={
|
|
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
|
-
<
|
|
210
|
-
|
|
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={(
|
|
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
|
|
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<
|
|
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
|
|
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(
|
|
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', '
|
|
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
|
|
442
|
-
<td style={{ padding: '0.625rem 0.75rem', color: 'var(--clearify-text-secondary)' }}>{p.group ?? '
|
|
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
|
|
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
|
|
42
|
+
href={projectHref(project)}
|
|
27
43
|
repo={project.repo}
|
|
28
44
|
status={project.status}
|
|
29
45
|
icon={project.icon}
|