-
-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathschema.ts
More file actions
317 lines (305 loc) · 9.23 KB
/
Copy pathschema.ts
File metadata and controls
317 lines (305 loc) · 9.23 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
/**
* Forum configuration schema, defaults, and helpers.
*
* User-facing configuration lives in `forum.config.ts` at the project root —
* everything there is optional and deep-merged over the defaults below.
*/
/** CSS theme tokens that can be overridden per colour scheme */
export type ThemeToken =
| 'background'
| 'foreground'
| 'muted'
| 'mutedForeground'
| 'card'
| 'cardForeground'
| 'border'
| 'primary'
| 'primaryForeground'
| 'accent'
| 'accentForeground'
| 'ring'
| 'link';
export type ThemeOverrides = Partial<Record<ThemeToken, string>>;
export interface NavLink {
label: string;
href: string;
/** Opens in a new tab */
external?: boolean;
}
export interface ForumConfig {
site: {
/** Forum name, shown in the header and page titles */
name: string;
/** Short tagline shown on the home page */
description: string;
/** Optional emoji used as the header logo instead of the default icon */
logo: string;
/** Optional footer line (supports no markup, plain text) */
footer: string;
};
repo: {
/**
* GitHub repository whose Discussions power the forum.
* Leave both empty to auto-detect from the GitHub Actions build
* environment (GITHUB_REPOSITORY) — forks then need no code changes.
*/
owner: string;
name: string;
};
/** Extra links shown in the header next to the logo */
nav: NavLink[];
auth: {
/** Allow sign-in by pasting a fine-grained personal access token */
allowToken: boolean;
/**
* "Sign in with GitHub" OAuth web flow. Requires a tiny token-exchange
* proxy (see README) because GitHub blocks browser CORS on the token
* endpoint. The proxy receives POST { code } → { access_token }.
*/
oauth: {
clientId: string;
proxyUrl: string;
};
};
admins: {
/** GitHub logins of forum admins — they get a badge next to their name */
logins: string[];
/** Badge text shown next to admin usernames */
badgeLabel: string;
};
/**
* Custom badges shown next to usernames, keyed by badge label:
* badges: { 'Moderator': ['alice'], 'Contributor': ['bob', 'carol'] }
* A user can hold any number of badges (in addition to the admin badge).
*/
badges: Record<string, string[]>;
content: {
/** Discussions fetched per page */
pageSize: number;
/** List ordering */
sort: 'CREATED_AT' | 'UPDATED_AT';
/**
* Show a two-line body excerpt on list rows. Disabling this (together
* with `articles.enabled`) lets list queries skip fetching post bodies
* entirely, which drastically shrinks payloads on busy forums.
*/
listExcerpts: boolean;
articles: {
/** Enable the long-form "article" post type */
enabled: boolean;
/** Hidden marker prepended to article bodies */
marker: string;
};
topics: {
/** Only show these category slugs (empty = all) */
include: string[];
/** Hide these category slugs */
exclude: string[];
/**
* Category slugs that only repository maintainers can post in
* (GitHub's "announcement" format categories). The API doesn't expose
* a category's format, so list them here; the forum then checks the
* viewer's repository permission (write/maintain/admin) before showing
* posting UI for them. GitHub enforces this server-side regardless.
*/
restricted: string[];
};
};
features: {
search: boolean;
reactions: boolean;
upvotes: boolean;
};
/**
* Optional reputation system: users earn rep for forum activity, and topics
* can require a minimum rep to post. The UI gates topics client-side; the
* `rep.yml` GitHub Actions workflow maintains the ledger and enforces the
* same rules against posts made directly on github.com (reactively — GitHub
* has no pre-post hook, so violating posts exist briefly before the
* workflow acts on them).
*/
rep: {
/** Master switch — everything below is inert when false */
enabled: boolean;
/** Rep awarded per action */
gains: {
post: number;
comment: number;
/** Granted by others, so it's weighted higher and never capped */
answerAccepted: number;
};
/**
* Anti-farming: max rep a user can earn per UTC day from each action
* type (0 = uncapped). Accepted answers are never capped.
*/
dailyCaps: { post: number; comment: number };
/** Category slug → minimum rep required to post there */
topics: Record<string, number>;
/** What the enforcement workflow does with an under-rep post */
onViolation: 'move' | 'lock' | 'delete';
/** Category slug posts are moved to when `onViolation` is 'move' */
fallbackTopic: string;
/** Users with push access (and config admins) bypass rep gates */
exemptMaintainers: boolean;
/** Data branch holding the Actions-maintained `profiles/` rep files */
dataBranch: string;
};
/**
* Optional cold-store archive: the data-sync workflow snapshots every
* discussion (rendered HTML + comments) to the data branch, and signed-out
* visitors browse that snapshot read-only instead of hitting the GitHub
* API (which requires auth). Public repositories only — the raw file URLs
* the SPA reads are not accessible anonymously on private repos.
*/
archive: {
/** Master switch for the archive and the anonymous read-only mode */
enabled: boolean;
/** Branch holding the archived `posts/` and `meta.json` (orphan, machine-written) */
dataBranch: string;
};
cache: {
/**
* Stale-while-revalidate caching of GraphQL responses in localStorage:
* pages render instantly from the last known data while a background
* request revalidates and updates them.
*/
enabled: boolean;
/** Hard expiry — entries older than this are never served (seconds) */
ttlSeconds: number;
};
theme: {
light: ThemeOverrides;
dark: ThemeOverrides;
};
}
/** Everything optional, one level deep per section */
export type UserForumConfig = {
[K in keyof ForumConfig]?: ForumConfig[K] extends object ? DeepPartial<ForumConfig[K]> : ForumConfig[K];
};
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends (infer U)[] ? U[] : T[K] extends object ? DeepPartial<T[K]> : T[K];
};
/** Identity helper so user config files get full type hints */
export function defineForumConfig(config: UserForumConfig): UserForumConfig {
return config;
}
export const defaultConfig: ForumConfig = {
site: {
name: 'Discussion Kit',
description: 'A community forum powered by GitHub Discussions',
logo: '',
footer: ''
},
repo: {
owner: '',
name: ''
},
nav: [],
auth: {
allowToken: true,
oauth: { clientId: '', proxyUrl: '' }
},
admins: {
logins: [],
badgeLabel: 'ADMIN'
},
badges: {},
content: {
pageSize: 25,
sort: 'CREATED_AT',
listExcerpts: true,
articles: {
enabled: true,
marker: '<!-- dk:article -->'
},
topics: { include: [], exclude: [], restricted: [] }
},
features: {
search: true,
reactions: true,
upvotes: true
},
rep: {
enabled: false,
gains: { post: 5, comment: 2, answerAccepted: 15 },
dailyCaps: { post: 25, comment: 10 },
topics: {},
onViolation: 'move',
fallbackTopic: '',
exemptMaintainers: true,
dataBranch: 'data'
},
archive: {
enabled: false,
dataBranch: 'data'
},
cache: {
enabled: true,
ttlSeconds: 3600
},
theme: { light: {}, dark: {} }
};
export function mergeConfig(user: UserForumConfig): ForumConfig {
// deep-clone so the resolved config never shares (or mutates) the defaults
const result = structuredClone(defaultConfig) as unknown as Record<string, unknown>;
const apply = (target: Record<string, unknown>, over: Record<string, unknown>) => {
for (const [key, value] of Object.entries(over)) {
if (value === undefined) continue;
const current = target[key];
if (
value !== null &&
typeof value === 'object' &&
!Array.isArray(value) &&
current !== null &&
typeof current === 'object' &&
!Array.isArray(current)
) {
apply(current as Record<string, unknown>, value as Record<string, unknown>);
} else {
target[key] = structuredClone(value);
}
}
};
apply(result, user);
return result as unknown as ForumConfig;
}
/**
* Fill in repo.owner/name from a "owner/name" string (the GITHUB_REPOSITORY
* value injected at build time) when the config leaves them empty.
*/
export function applyRepoFallback(config: ForumConfig, repository: string): void {
if (config.repo.owner && config.repo.name) return;
const [owner, name] = repository.split('/');
if (owner && name) {
config.repo.owner ||= owner;
config.repo.name ||= name;
}
}
const TOKEN_TO_VAR: Record<ThemeToken, string> = {
background: '--fd-background',
foreground: '--fd-foreground',
muted: '--fd-muted',
mutedForeground: '--fd-muted-foreground',
card: '--fd-card',
cardForeground: '--fd-card-foreground',
border: '--fd-border',
primary: '--fd-primary',
primaryForeground: '--fd-primary-foreground',
accent: '--fd-accent',
accentForeground: '--fd-accent-foreground',
ring: '--fd-ring',
link: '--fd-link'
};
/** Compile theme overrides into a CSS string ('' when nothing is overridden) */
export function buildThemeCss(theme: ForumConfig['theme']): string {
const rules = (overrides: ThemeOverrides) =>
Object.entries(overrides)
.map(([token, value]) => `${TOKEN_TO_VAR[token as ThemeToken]}:${value};`)
.join('');
const light = rules(theme.light);
const dark = rules(theme.dark);
let css = '';
if (light) css += `:root{${light}}`;
if (dark) css += `.dark{${dark}}`;
return css;
}