@grove-dev/core 0.1.4 → 0.2.2

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/schema.js CHANGED
@@ -1,6 +1,31 @@
1
1
  import { z } from "zod";
2
2
  import { parse, stringify } from "yaml";
3
3
  // ──────────────────────────────────────────────────────────────────────
4
+ // Blueprints and kinds
5
+ // ──────────────────────────────────────────────────────────────────────
6
+ /**
7
+ * A blueprint defines the shape and behavior of a Grove site.
8
+ *
9
+ * V1 ships three fixed blueprints. They are not extensible — no custom
10
+ * blueprint API in V1.
11
+ */
12
+ export const blueprintSchema = z.enum([
13
+ "project-directory",
14
+ "resource-hub",
15
+ "ecosystem-map",
16
+ ]);
17
+ /**
18
+ * Each blueprint is bound to a single resource kind. Records are
19
+ * discriminated by `kind`, which must match the blueprint of the site
20
+ * that owns them.
21
+ */
22
+ export const resourceKindSchema = z.enum(["project", "resource", "entity"]);
23
+ export const blueprintKind = {
24
+ "project-directory": "project",
25
+ "resource-hub": "resource",
26
+ "ecosystem-map": "entity",
27
+ };
28
+ // ──────────────────────────────────────────────────────────────────────
4
29
  // Health & decision labels
5
30
  // ──────────────────────────────────────────────────────────────────────
6
31
  export const healthStatusSchema = z.enum([
@@ -30,7 +55,7 @@ export const healthTierSchema = z.enum([
30
55
  "hidden",
31
56
  ]);
32
57
  // ──────────────────────────────────────────────────────────────────────
33
- // Curation: project shape
58
+ // Curation enums (shared)
34
59
  // ──────────────────────────────────────────────────────────────────────
35
60
  export const projectTypeSchema = z.enum([
36
61
  "real-app",
@@ -42,8 +67,32 @@ export const projectTypeSchema = z.enum([
42
67
  "template",
43
68
  "historical",
44
69
  ]);
45
- export const difficultySchema = z.enum(["beginner", "intermediate", "advanced"]);
46
- export const codebaseSizeSchema = z.enum(["small", "medium", "large", "huge"]);
70
+ export const resourceTypeSchema = z.enum([
71
+ "guide",
72
+ "comparison",
73
+ "link",
74
+ "explainer",
75
+ "tool",
76
+ "video",
77
+ "article",
78
+ "course",
79
+ "book",
80
+ "podcast",
81
+ "other",
82
+ ]);
83
+ export const entityTypeSchema = z.enum([
84
+ "company",
85
+ "organization",
86
+ "community",
87
+ "school",
88
+ "university",
89
+ "research-lab",
90
+ "agency",
91
+ "service",
92
+ "product",
93
+ "person",
94
+ "other",
95
+ ]);
47
96
  export const appLabelSchema = z.enum(["new", "hot", "mature", "featured"]);
48
97
  export const scoreSchema = z.object({
49
98
  activity: z.number().min(0).max(100).optional(),
@@ -54,34 +103,19 @@ export const scoreSchema = z.object({
54
103
  overall: z.number().min(0).max(100).optional(),
55
104
  });
56
105
  // ──────────────────────────────────────────────────────────────────────
57
- // Distribution channels
58
- // ──────────────────────────────────────────────────────────────────────
59
- export const distributionChannelTypeSchema = z.enum([
60
- "app-store",
61
- "play-store",
62
- "fdroid",
63
- "github-releases",
64
- "apk",
65
- "testflight",
66
- "website",
67
- "snapcraft",
68
- "flathub",
69
- "microsoft-store",
70
- "package-registry",
71
- "docs",
72
- "demo",
73
- "other",
74
- ]);
75
- export const distributionChannelSchema = z.object({
76
- type: distributionChannelTypeSchema,
77
- platform: z.string().optional(),
78
- label: z.string().optional(),
79
- url: z.string().url(),
80
- verified: z.boolean().optional(),
81
- notes: z.string().optional(),
82
- });
106
+ // Links
83
107
  // ──────────────────────────────────────────────────────────────────────
84
- // GitHub-shaped metadata
108
+ export const linksSchema = z
109
+ .object({
110
+ github: z.string().url().optional(),
111
+ website: z.string().url().optional(),
112
+ docs: z.string().url().optional(),
113
+ source: z.string().url().optional(),
114
+ })
115
+ .catchall(z.string().url())
116
+ .default({});
117
+ // ──────────────────────────────────────────────────────────────────────
118
+ // Health metadata
85
119
  // ──────────────────────────────────────────────────────────────────────
86
120
  export const githubLicenseSchema = z
87
121
  .object({
@@ -125,205 +159,6 @@ export const githubRepositorySchema = z
125
159
  pushed_at: z.string().nullable().optional(),
126
160
  })
127
161
  .passthrough();
128
- // ──────────────────────────────────────────────────────────────────────
129
- // Stack (registry-backed taxonomy)
130
- // ──────────────────────────────────────────────────────────────────────
131
- export const stackTechnologySchema = z
132
- .object({
133
- id: z.string().min(1),
134
- role: z.string().optional(),
135
- })
136
- .passthrough();
137
- export const stackSchema = z
138
- .object({
139
- primary: z.string().min(1),
140
- families: z.array(z.string()).optional(),
141
- technologies: z.array(stackTechnologySchema).optional(),
142
- })
143
- .passthrough();
144
- export const stackRefSchema = z.object({
145
- id: z.string().min(1),
146
- name: z.string().min(1),
147
- family: z.string().optional(),
148
- languages: z.array(z.string()).optional(),
149
- platforms: z.array(z.string()).optional(),
150
- icon: z.string().optional(),
151
- status: z.enum(["live", "expanding", "planned"]).optional(),
152
- blurb: z.string().optional(),
153
- });
154
- export const platformRefSchema = z.object({
155
- id: z.string().min(1),
156
- name: z.string().min(1),
157
- icon: z.string().optional(),
158
- });
159
- export const categoryRefSchema = z.object({
160
- id: z.string().min(1),
161
- name: z.string().min(1),
162
- blurb: z.string().optional(),
163
- });
164
- export const distributionChannelRefSchema = z.object({
165
- id: distributionChannelTypeSchema,
166
- name: z.string().min(1),
167
- platforms: z.array(z.string()).optional(),
168
- });
169
- // ──────────────────────────────────────────────────────────────────────
170
- // Item — generic open-source project record
171
- // ──────────────────────────────────────────────────────────────────────
172
- export const itemSchema = z.object({
173
- id: z.string().min(1),
174
- name: z.string().min(1),
175
- description: z.string().default(""),
176
- links: z
177
- .object({
178
- github: z.string().url().optional(),
179
- website: z.string().url().optional(),
180
- docs: z.string().url().optional(),
181
- source: z.string().url().optional(),
182
- })
183
- .catchall(z.string().url())
184
- .default({}),
185
- source: z
186
- .object({
187
- type: z.enum(["markdown", "manual", "github-topic", "awesome-list", "submit"]).default("manual"),
188
- file: z.string().optional(),
189
- line: z.number().int().positive().optional(),
190
- url: z.string().optional(),
191
- provider: z.string().optional(),
192
- owner: z.string().optional(),
193
- repo: z.string().optional(),
194
- })
195
- .default({ type: "manual" }),
196
- taxonomy: z
197
- .object({
198
- category: z.string().min(1).default("uncategorized"),
199
- tags: z.array(z.string()).default([]),
200
- language: z.string().optional(),
201
- stack: z.string().optional(),
202
- stacks: z.array(z.string()).default([]),
203
- platforms: z.array(z.string()).default([]),
204
- })
205
- .default({
206
- category: "uncategorized",
207
- tags: [],
208
- stacks: [],
209
- platforms: [],
210
- }),
211
- labels: z.array(appLabelSchema).default([]),
212
- lenses: z.array(z.string()).default([]),
213
- distribution: z
214
- .object({
215
- channels: z.array(distributionChannelSchema).default([]),
216
- })
217
- .default({ channels: [] }),
218
- curation: z
219
- .object({
220
- projectType: projectTypeSchema.optional(),
221
- difficulty: difficultySchema.optional(),
222
- codebaseSize: codebaseSizeSchema.optional(),
223
- stateManagement: z.string().optional(),
224
- backend: z.string().optional(),
225
- architecture: z.string().optional(),
226
- bestFor: z.array(z.string()).default([]),
227
- whyListed: z.array(z.string()).default([]),
228
- caveats: z.array(z.string()).default([]),
229
- goodFirstIssues: z.union([z.boolean(), z.string().url()]).optional(),
230
- contributionGuide: z.union([z.boolean(), z.string().url()]).optional(),
231
- launchedBy: z.string().optional(),
232
- launchAsk: z.array(z.string()).default([]),
233
- reviewed: z.boolean().optional(),
234
- reviewedBy: z.string().optional(),
235
- reviewedAt: z.string().optional(),
236
- notes: z.string().optional(),
237
- scores: scoreSchema.default({}),
238
- })
239
- .default({
240
- bestFor: [],
241
- whyListed: [],
242
- caveats: [],
243
- launchAsk: [],
244
- scores: {},
245
- }),
246
- });
247
- export const itemsFileSchema = z.union([
248
- z.array(itemSchema),
249
- z.object({ items: z.array(itemSchema) }),
250
- ]);
251
- // ──────────────────────────────────────────────────────────────────────
252
- // GitHub-shaped app record (schema v1)
253
- // ──────────────────────────────────────────────────────────────────────
254
- const isoLike = z.string().min(4).nullable().optional();
255
- const finalAppSchema = z
256
- .object({
257
- schemaVersion: z.number().optional(),
258
- id: z.string().optional(),
259
- slug: z.string().min(1),
260
- source: z
261
- .object({
262
- provider: z.literal("github"),
263
- owner: z.string().min(1),
264
- repo: z.string().min(1),
265
- url: z.string().url().optional(),
266
- })
267
- .passthrough(),
268
- app: z
269
- .object({
270
- name: z.string().min(1),
271
- description: z.string().min(1),
272
- category: z.string().min(1),
273
- projectType: z.string().optional(),
274
- platforms: z.array(z.string()).min(1),
275
- tags: z.array(z.string()).optional(),
276
- distribution: z
277
- .object({
278
- channels: z
279
- .array(z
280
- .object({
281
- type: z.string().min(1),
282
- platform: z.string().optional(),
283
- label: z.string().optional(),
284
- url: z.string().url(),
285
- verified: z.boolean().optional(),
286
- notes: z.string().optional(),
287
- })
288
- .passthrough())
289
- .optional(),
290
- })
291
- .passthrough()
292
- .optional(),
293
- })
294
- .passthrough(),
295
- stack: stackSchema,
296
- github: z
297
- .object({
298
- repository: githubRepositorySchema.optional(),
299
- languages: z.record(z.string(), z.number()).optional(),
300
- latestRelease: z.record(z.string(), z.unknown()).nullable().optional(),
301
- activity: z.record(z.string(), z.unknown()).optional(),
302
- files: z.record(z.string(), z.boolean()).optional(),
303
- labels: z.array(z.record(z.string(), z.unknown())).optional(),
304
- sync: z.record(z.string(), z.unknown()).optional(),
305
- })
306
- .passthrough()
307
- .optional(),
308
- health: z.record(z.string(), z.unknown()).optional(),
309
- curation: z.record(z.string(), z.unknown()).optional(),
310
- })
311
- .passthrough();
312
- const legacyAppSchema = z
313
- .object({
314
- slug: z.string().min(1),
315
- name: z.string().min(1),
316
- repoUrl: z.string().url(),
317
- description: z.string().min(1),
318
- stack: z.union([z.string(), z.record(z.string(), z.unknown())]),
319
- stacks: z.array(z.string()).optional(),
320
- platforms: z.array(z.string()).default([]),
321
- category: z.string().min(1),
322
- })
323
- .passthrough();
324
- // ──────────────────────────────────────────────────────────────────────
325
- // Health metadata
326
- // ──────────────────────────────────────────────────────────────────────
327
162
  export const githubMetadataSchema = z.object({
328
163
  fullName: z.string().optional(),
329
164
  stars: z.number().int().nonnegative().default(0),
@@ -356,19 +191,20 @@ export const githubMetadataSchema = z.object({
356
191
  ]))
357
192
  .optional(),
358
193
  });
194
+ export const healthBlockSchema = z.object({
195
+ status: healthStatusSchema.default("unknown"),
196
+ maturity: z.enum(["experimental", "useful", "mature", "unknown"]).default("unknown"),
197
+ tier: healthTierSchema.default("experimental"),
198
+ visibility: decisionVisibilitySchema.default("keep"),
199
+ cleanupCandidate: z.boolean().default(false),
200
+ staleReason: z.string().nullable().optional(),
201
+ confidence: z.enum(["low", "medium", "high"]).default("medium"),
202
+ reasons: z.array(z.string()).default([]),
203
+ });
359
204
  export const healthEntrySchema = z.object({
360
205
  id: z.string().min(1),
361
206
  github: githubMetadataSchema.optional(),
362
- health: z.object({
363
- status: healthStatusSchema,
364
- maturity: z.enum(["experimental", "useful", "mature", "unknown"]).default("unknown"),
365
- tier: healthTierSchema.default("experimental"),
366
- visibility: decisionVisibilitySchema.default("keep"),
367
- cleanupCandidate: z.boolean().default(false),
368
- staleReason: z.string().nullable().optional(),
369
- confidence: z.enum(["low", "medium", "high"]).default("medium"),
370
- reasons: z.array(z.string()).default([]),
371
- }),
207
+ health: healthBlockSchema,
372
208
  });
373
209
  export const healthFileSchema = z.union([
374
210
  z.array(healthEntrySchema),
@@ -391,7 +227,7 @@ export const decisionsFileSchema = z.union([
391
227
  z.object({ decisions: z.array(decisionSchema) }),
392
228
  ]);
393
229
  // ──────────────────────────────────────────────────────────────────────
394
- // Overrides (manual corrections to imported items)
230
+ // Overrides (manual corrections to imported records)
395
231
  // ──────────────────────────────────────────────────────────────────────
396
232
  export const overrideSchema = z.object({
397
233
  id: z.string().min(1),
@@ -402,36 +238,208 @@ export const overridesFileSchema = z.union([
402
238
  z.object({ overrides: z.array(overrideSchema) }),
403
239
  ]);
404
240
  // ──────────────────────────────────────────────────────────────────────
405
- // Curated config
241
+ // Curation: shared block
242
+ // ──────────────────────────────────────────────────────────────────────
243
+ const curationBlockSchema = z
244
+ .object({
245
+ reviewed: z.boolean().default(false),
246
+ reviewedBy: z.string().optional(),
247
+ reviewedAt: z.string().optional(),
248
+ notes: z.string().optional(),
249
+ labels: z.array(appLabelSchema).default([]),
250
+ lenses: z.array(z.string()).default([]),
251
+ })
252
+ .default({ reviewed: false, labels: [], lenses: [] });
253
+ // ──────────────────────────────────────────────────────────────────────
254
+ // Resource base (shared by all blueprints)
255
+ // ──────────────────────────────────────────────────────────────────────
256
+ const resourceBaseSchema = z.object({
257
+ slug: z.string().min(1),
258
+ description: z.string().default(""),
259
+ category: z.string().min(1).default("uncategorized"),
260
+ tags: z.array(z.string()).default([]),
261
+ links: linksSchema,
262
+ content: z.string().optional(), // path to markdown body under content/records/
263
+ source: z
264
+ .object({
265
+ type: z.enum(["manual", "github-topic", "awesome-list", "submit", "import"])
266
+ .default("manual"),
267
+ file: z.string().optional(),
268
+ url: z.string().optional(),
269
+ provider: z.string().optional(),
270
+ owner: z.string().optional(),
271
+ repo: z.string().optional(),
272
+ })
273
+ .default({ type: "manual" }),
274
+ curation: curationBlockSchema,
275
+ scores: scoreSchema.default({}),
276
+ });
406
277
  // ──────────────────────────────────────────────────────────────────────
407
- export const curatedConfigSchema = z.object({
278
+ // Blueprint: project-directory — kind: project
279
+ // ──────────────────────────────────────────────────────────────────────
280
+ export const projectRecordSchema = resourceBaseSchema.extend({
281
+ kind: z.literal("project"),
408
282
  name: z.string().min(1),
409
- tagline: z.string().default("A living, health-aware developer directory."),
410
- description: z.string().optional(),
411
- siteUrl: z.string().url().optional(),
283
+ projectType: projectTypeSchema.optional(),
284
+ stack: z.string().optional(),
285
+ stacks: z.array(z.string()).default([]),
286
+ platforms: z.array(z.string()).default([]),
287
+ difficulty: z.enum(["beginner", "intermediate", "advanced"]).optional(),
288
+ codebaseSize: z.enum(["small", "medium", "large", "huge"]).optional(),
289
+ // Canonical repo URL. Distinct from `links.github`, which is the
290
+ // human-facing link on the page; `repoUrl` is the single source
291
+ // for owner/repo extraction, avatar fallback, and "view repo" CTAs.
412
292
  repoUrl: z.string().url().optional(),
413
- itemLabel: z.string().default("project"),
293
+ // Optional project logo/avatar URL. Falls back to the GitHub
294
+ // owner avatar (when `repoUrl` is a github.com URL) or to the
295
+ // first-2-letters initials placeholder.
296
+ logoUrl: z.string().url().optional(),
297
+ bestFor: z.array(z.string()).default([]),
298
+ whyListed: z.array(z.string()).default([]),
299
+ caveats: z.array(z.string()).default([]),
300
+ distribution: z
301
+ .object({
302
+ channels: z
303
+ .array(z
304
+ .object({
305
+ type: z.string().min(1),
306
+ platform: z.string().optional(),
307
+ label: z.string().optional(),
308
+ url: z.string().url(),
309
+ verified: z.boolean().optional(),
310
+ notes: z.string().optional(),
311
+ })
312
+ .passthrough())
313
+ .default([]),
314
+ })
315
+ .default({ channels: [] }),
316
+ github: z
317
+ .object({
318
+ repository: githubRepositorySchema.optional(),
319
+ languages: z.record(z.string(), z.number()).optional(),
320
+ latestRelease: z.record(z.string(), z.unknown()).nullable().optional(),
321
+ activity: z.record(z.string(), z.unknown()).optional(),
322
+ files: z.record(z.string(), z.boolean()).optional(),
323
+ labels: z.array(z.record(z.string(), z.unknown())).optional(),
324
+ sync: z.record(z.string(), z.unknown()).optional(),
325
+ })
326
+ .passthrough()
327
+ .optional(),
328
+ health: healthBlockSchema.optional(),
329
+ });
330
+ // ──────────────────────────────────────────────────────────────────────
331
+ // Blueprint: resource-hub — kind: resource
332
+ // ──────────────────────────────────────────────────────────────────────
333
+ export const resourceRecordSchema = resourceBaseSchema.extend({
334
+ kind: z.literal("resource"),
335
+ title: z.string().min(1),
336
+ type: resourceTypeSchema,
337
+ topic: z.string().min(1),
338
+ related: z.array(z.string()).default([]), // slugs of related resources
339
+ publishedAt: z.string().optional(),
340
+ author: z.string().optional(),
341
+ });
342
+ // ──────────────────────────────────────────────────────────────────────
343
+ // Blueprint: ecosystem-map — kind: entity
344
+ // ──────────────────────────────────────────────────────────────────────
345
+ export const entityRecordSchema = resourceBaseSchema.extend({
346
+ kind: z.literal("entity"),
347
+ name: z.string().min(1),
348
+ type: entityTypeSchema,
349
+ founded: z.string().optional(),
350
+ location: z.string().optional(),
351
+ members: z.number().int().nonnegative().optional(),
352
+ parent: z.string().optional(), // slug of parent entity
353
+ });
354
+ // ──────────────────────────────────────────────────────────────────────
355
+ // Discriminated union: Resource
356
+ // ──────────────────────────────────────────────────────────────────────
357
+ export const resourceSchema = z.discriminatedUnion("kind", [
358
+ projectRecordSchema,
359
+ resourceRecordSchema,
360
+ entityRecordSchema,
361
+ ]);
362
+ // ──────────────────────────────────────────────────────────────────────
363
+ // Records file (one or many resources per file is also supported)
364
+ // ──────────────────────────────────────────────────────────────────────
365
+ export const recordsFileSchema = z.union([
366
+ z.array(resourceSchema),
367
+ z.object({ records: z.array(resourceSchema) }),
368
+ ]);
369
+ // ──────────────────────────────────────────────────────────────────────
370
+ // Grove config
371
+ // ──────────────────────────────────────────────────────────────────────
372
+ export const navItemSchema = z.object({
373
+ label: z.string().min(1),
374
+ href: z.string().min(1),
375
+ });
376
+ export const githubIntegrationSchema = z.union([
377
+ z.boolean(),
378
+ z.object({
379
+ metadata: z.boolean().default(false),
380
+ contributors: z.boolean().default(false),
381
+ health: z.boolean().default(false),
382
+ }),
383
+ ]);
384
+ export const themeSchema = z.object({
385
+ primaryColor: z.string().default("#16a34a"),
386
+ radius: z.enum(["none", "soft", "round"]).default("soft"),
387
+ density: z.enum(["compact", "comfortable", "spacious"]).default("comfortable"),
388
+ containerWidth: z.string().default("72rem"),
389
+ });
390
+ export const componentOverrideSchema = z.object({
391
+ Header: z.string().optional(),
392
+ Footer: z.string().optional(),
393
+ Hero: z.string().optional(),
394
+ ItemCard: z.string().optional(),
395
+ DetailHeader: z.string().optional(),
396
+ });
397
+ export const groveConfigSchema = z.object({
398
+ blueprint: blueprintSchema.default("project-directory"),
399
+ site: z.object({
400
+ name: z.string().min(1),
401
+ tagline: z.string().default("A growing community knowledge site."),
402
+ description: z.string().optional(),
403
+ url: z.string().url().optional(),
404
+ repoUrl: z.string().url().optional(),
405
+ }),
406
+ nav: z.array(navItemSchema).default([]),
407
+ facets: z.array(z.string()).default(["category", "tags"]),
408
+ integrations: z
409
+ .object({
410
+ github: githubIntegrationSchema.default(false),
411
+ })
412
+ .default({ github: false }),
413
+ theme: themeSchema.default({
414
+ primaryColor: "#16a34a",
415
+ radius: "soft",
416
+ density: "comfortable",
417
+ containerWidth: "72rem",
418
+ }),
419
+ components: componentOverrideSchema.default({}),
414
420
  paths: z
415
421
  .object({
416
- sourcesDir: z.string().default("sources"),
417
422
  dataDir: z.string().default("data"),
418
423
  contentDir: z.string().default("content"),
419
- appsDir: z.string().default("data/apps"),
424
+ recordsDir: z.string().default("data/records"),
425
+ pagesDir: z.string().default("content/pages"),
426
+ bodiesDir: z.string().default("content/records"),
427
+ publicDir: z.string().default("public"),
420
428
  taxonomyDir: z.string().default("data/taxonomy"),
421
429
  generatedDir: z.string().default("data/generated"),
422
- items: z.string().default("data/items.yml"),
423
430
  health: z.string().default("data/health.yml"),
424
431
  decisions: z.string().default("data/decisions.yml"),
425
432
  overrides: z.string().default("data/overrides.yml"),
426
433
  })
427
434
  .default({
428
- sourcesDir: "sources",
429
435
  dataDir: "data",
430
436
  contentDir: "content",
431
- appsDir: "data/apps",
437
+ recordsDir: "data/records",
438
+ pagesDir: "content/pages",
439
+ bodiesDir: "content/records",
440
+ publicDir: "public",
432
441
  taxonomyDir: "data/taxonomy",
433
442
  generatedDir: "data/generated",
434
- items: "data/items.yml",
435
443
  health: "data/health.yml",
436
444
  decisions: "data/decisions.yml",
437
445
  overrides: "data/overrides.yml",
@@ -440,8 +448,8 @@ export const curatedConfigSchema = z.object({
440
448
  // ──────────────────────────────────────────────────────────────────────
441
449
  // Helpers
442
450
  // ──────────────────────────────────────────────────────────────────────
443
- export function unwrapItems(value) {
444
- return Array.isArray(value) ? value : value.items;
451
+ export function unwrapRecords(value) {
452
+ return Array.isArray(value) ? value : value.records;
445
453
  }
446
454
  export function unwrapHealth(value) {
447
455
  return Array.isArray(value) ? value : value.health;
@@ -452,17 +460,138 @@ export function unwrapDecisions(value) {
452
460
  export function unwrapOverrides(value) {
453
461
  return Array.isArray(value) ? value : value.overrides;
454
462
  }
455
- function compactArray(values) {
456
- return [...new Set((values ?? []).filter(Boolean))];
463
+ /**
464
+ * Parse a single record YAML mapping into a typed Resource. The
465
+ * `kind` field on the YAML selects which blueprint schema to apply.
466
+ */
467
+ export function parseRecordYaml(text, fileSlug) {
468
+ const raw = parse(text) ?? {};
469
+ if (typeof raw !== "object" || Array.isArray(raw)) {
470
+ throw new Error(`${fileSlug}: record file must contain a YAML mapping`);
471
+ }
472
+ return raw;
473
+ }
474
+ export function stringifyRecordYaml(record) {
475
+ return stringify(record, {
476
+ lineWidth: 100,
477
+ singleQuote: false,
478
+ defaultStringType: "PLAIN",
479
+ });
457
480
  }
458
- function formatDateOnly(value) {
459
- if (!value)
460
- return undefined;
461
- const d = new Date(value);
462
- if (Number.isNaN(d.valueOf()))
463
- return String(value);
464
- return d.toISOString().slice(0, 10);
481
+ export function getOwnerRepoFromUrl(repoUrl) {
482
+ if (!repoUrl)
483
+ return null;
484
+ const m = String(repoUrl).match(/github\.com\/([^/]+)\/([^/?#]+)/);
485
+ if (!m)
486
+ return null;
487
+ return { owner: m[1], repo: m[2].replace(/\.git$/, "") };
488
+ }
489
+ /**
490
+ * Normalize a raw record mapping into a typed Resource. The `kind`
491
+ * field must match the blueprint of the site that owns the record;
492
+ * mismatch produces a clear ZodError.
493
+ */
494
+ export function normalizeRecord(raw, fileSlug, blueprint = "project-directory") {
495
+ const expectedKind = blueprintKind[blueprint];
496
+ if (!raw.kind) {
497
+ raw = { ...raw, kind: expectedKind };
498
+ }
499
+ else if (raw.kind !== expectedKind) {
500
+ throw new Error(`${fileSlug}: kind "${raw.kind}" does not match blueprint "${blueprint}" (expected "${expectedKind}")`);
501
+ }
502
+ return resourceSchema.parse(raw);
503
+ }
504
+ export function validateRecord(raw, fileSlug, blueprint) {
505
+ try {
506
+ normalizeRecord(raw, fileSlug, blueprint);
507
+ return [];
508
+ }
509
+ catch (err) {
510
+ if (err instanceof z.ZodError) {
511
+ return err.issues.map((issue) => `${fileSlug}: ${issue.path.join(".") || "(root)"} ${issue.message}`);
512
+ }
513
+ return [`${fileSlug}: ${err.message}`];
514
+ }
515
+ }
516
+ /**
517
+ * Project a Resource to a stable, search-index-friendly summary
518
+ * object. Strips out blueprint-specific heavy fields and leaves
519
+ * only what the public list/detail UIs need.
520
+ */
521
+ export function toIndexRecord(record) {
522
+ const base = {
523
+ slug: record.slug,
524
+ kind: record.kind,
525
+ category: record.category,
526
+ tags: record.tags ?? [],
527
+ links: record.links,
528
+ description: record.description,
529
+ content: record.content,
530
+ curation: record.curation,
531
+ };
532
+ if (record.kind === "project") {
533
+ return {
534
+ ...base,
535
+ name: record.name,
536
+ stack: record.stack,
537
+ stacks: record.stacks ?? [],
538
+ platforms: record.platforms ?? [],
539
+ projectType: record.projectType,
540
+ repoUrl: record.repoUrl,
541
+ logoUrl: record.logoUrl,
542
+ difficulty: record.difficulty,
543
+ codebaseSize: record.codebaseSize,
544
+ bestFor: record.bestFor,
545
+ whyListed: record.whyListed,
546
+ caveats: record.caveats,
547
+ // Health surfaced alongside the record so list/detail UIs can
548
+ // show staleness/curation tier without a second lookup.
549
+ health: record.health,
550
+ // Visibility comes from either the health block (auto-derived
551
+ // from signals) or the curation override (`decisions.yml`).
552
+ // The generate step folds the latter in before serialising,
553
+ // so this is a best-effort projection of the current state.
554
+ visibility: record.health?.visibility ?? "keep",
555
+ github: record.github?.repository
556
+ ? {
557
+ fullName: record.github.repository.full_name,
558
+ stars: record.github.repository.stargazers_count ?? 0,
559
+ forks: record.github.repository.forks_count ?? 0,
560
+ openIssues: record.github.repository.open_issues_count ?? 0,
561
+ language: record.github.repository.language ?? null,
562
+ pushedAt: record.github.repository.pushed_at ?? null,
563
+ archived: Boolean(record.github.repository.archived),
564
+ license: record.github.repository.license?.spdx_id ?? null,
565
+ topics: record.github.repository.topics ?? [],
566
+ }
567
+ : undefined,
568
+ };
569
+ }
570
+ if (record.kind === "resource") {
571
+ return {
572
+ ...base,
573
+ title: record.title,
574
+ type: record.type,
575
+ topic: record.topic,
576
+ related: record.related,
577
+ publishedAt: record.publishedAt,
578
+ author: record.author,
579
+ };
580
+ }
581
+ // entity
582
+ return {
583
+ ...base,
584
+ name: record.name,
585
+ type: record.type,
586
+ founded: record.founded,
587
+ location: record.location,
588
+ members: record.members,
589
+ parent: record.parent,
590
+ };
465
591
  }
592
+ // ──────────────────────────────────────────────────────────────────────
593
+ // Health signal derivation
594
+ // ──────────────────────────────────────────────────────────────────────
466
595
  function daysSince(value) {
467
596
  if (!value)
468
597
  return Infinity;
@@ -472,7 +601,7 @@ function daysSince(value) {
472
601
  return (Date.now() - d.valueOf()) / 86_400_000;
473
602
  }
474
603
  export function healthFromSignals(params) {
475
- const { repo, monthlyCommits, legacyStatus } = params;
604
+ const { repo, monthlyCommits } = params;
476
605
  const stars = repo?.stargazers_count ?? 0;
477
606
  const lastCommitAt = repo?.pushed_at;
478
607
  const archived = Boolean(repo?.archived);
@@ -503,10 +632,13 @@ export function healthFromSignals(params) {
503
632
  ? "listed"
504
633
  : "experimental";
505
634
  return {
506
- status: legacyStatus ?? status,
635
+ status,
507
636
  tier,
508
637
  visibility: tier === "hidden" ? "hide" : "keep",
509
- cleanupCandidate: status === "stale" || status === "archived" || status === "inactive" || status === "unavailable",
638
+ cleanupCandidate: status === "stale" ||
639
+ status === "archived" ||
640
+ status === "inactive" ||
641
+ status === "unavailable",
510
642
  staleReason: status === "inactive"
511
643
  ? "no_commits_24_months"
512
644
  : status === "stale"
@@ -518,212 +650,4 @@ export function healthFromSignals(params) {
518
650
  : null,
519
651
  };
520
652
  }
521
- export function parseAppYaml(text, fileSlug) {
522
- const raw = parse(text) ?? {};
523
- if (typeof raw !== "object" || Array.isArray(raw)) {
524
- throw new Error(`${fileSlug}: app file must contain a YAML mapping`);
525
- }
526
- return raw;
527
- }
528
- export function stringifyAppYaml(app) {
529
- return stringify(app, {
530
- lineWidth: 100,
531
- singleQuote: false,
532
- defaultStringType: "PLAIN",
533
- });
534
- }
535
- export function getOwnerRepoFromUrl(repoUrl) {
536
- if (!repoUrl)
537
- return null;
538
- const m = String(repoUrl).match(/github\.com\/([^/]+)\/([^/?#]+)/);
539
- if (!m)
540
- return null;
541
- return { owner: m[1], repo: m[2].replace(/\.git$/, "") };
542
- }
543
- export function normalizeAppRecord(raw, fileSlug) {
544
- const hasFinalShape = typeof raw.app === "object" &&
545
- raw.app !== null &&
546
- typeof raw.source === "object" &&
547
- raw.source !== null &&
548
- raw.github !== undefined;
549
- if (hasFinalShape) {
550
- return normalizeFinal(raw, fileSlug);
551
- }
552
- return normalizeLegacy(raw, fileSlug);
553
- }
554
- function normalizeFinal(raw, fileSlug) {
555
- const parsed = finalAppSchema.parse(raw);
556
- const repo = parsed.github?.repository;
557
- const repoUrl = parsed.source.url ?? repo?.html_url ?? `https://github.com/${parsed.source.owner}/${parsed.source.repo}`;
558
- const activity = parsed.github?.activity ?? {};
559
- const monthlyCommits = activity.monthlyCommits;
560
- const health = {
561
- ...healthFromSignals({ repo, monthlyCommits }),
562
- ...parsed.health,
563
- };
564
- const technologies = compactArray((parsed.stack.technologies ?? []).map((t) => t.id));
565
- const secondaryStacks = technologies.filter((id) => id !== parsed.stack.primary);
566
- const curation = parsed.curation ?? {};
567
- return {
568
- ...parsed,
569
- schemaVersion: 1,
570
- slug: parsed.slug || fileSlug,
571
- name: parsed.app.name,
572
- description: parsed.app.description,
573
- repoUrl,
574
- homepageUrl: repo?.homepage || raw.homepageUrl || undefined,
575
- stack: parsed.stack.primary,
576
- stacks: secondaryStacks,
577
- platforms: parsed.app.platforms,
578
- distribution: parsed.app.distribution ?? { channels: [] },
579
- category: parsed.app.category,
580
- tags: compactArray([...(parsed.app.tags ?? []), ...(repo?.topics ?? [])]),
581
- license: repo?.license?.spdx_id || undefined,
582
- status: health.status,
583
- stars: repo?.stargazers_count,
584
- forks: repo?.forks_count,
585
- openIssues: repo?.open_issues_count,
586
- lastCommitAt: formatDateOnly(repo?.pushed_at) ?? null,
587
- labels: curation.labels ?? [],
588
- lenses: curation.lenses ?? [],
589
- projectType: parsed.app.projectType,
590
- difficulty: curation.difficulty,
591
- codebaseSize: curation.codebaseSize,
592
- bestFor: curation.bestFor ?? [],
593
- whyListed: curation.whyListed ?? [],
594
- caveats: curation.caveats ?? [],
595
- scores: curation.scores ?? {},
596
- curation: {
597
- reviewed: Boolean(curation.reviewed),
598
- reviewedBy: curation.reviewedBy,
599
- reviewedAt: curation.reviewedAt,
600
- notes: curation.notes,
601
- },
602
- github: parsed.github,
603
- health,
604
- tier: health.tier,
605
- };
606
- }
607
- function normalizeLegacy(raw, fileSlug) {
608
- const parsed = legacyAppSchema.parse({ ...raw, slug: raw.slug ?? fileSlug });
609
- const ownerRepo = getOwnerRepoFromUrl(parsed.repoUrl);
610
- const activity = parsed.activity ?? {};
611
- const repo = {
612
- full_name: ownerRepo ? `${ownerRepo.owner}/${ownerRepo.repo}` : undefined,
613
- name: ownerRepo?.repo,
614
- html_url: parsed.repoUrl,
615
- homepage: parsed.homepageUrl,
616
- description: parsed.description,
617
- fork: false,
618
- archived: parsed.status === "archived",
619
- disabled: false,
620
- private: false,
621
- visibility: "public",
622
- language: typeof parsed.stack === "string" ? parsed.stack : undefined,
623
- license: parsed.license ? { spdx_id: parsed.license } : undefined,
624
- stargazers_count: activity.stars ?? parsed.stars,
625
- watchers_count: activity.watchers,
626
- forks_count: activity.forks ?? parsed.forks,
627
- open_issues_count: activity.openIssues,
628
- subscribers_count: activity.contributors,
629
- pushed_at: activity.lastCommitAt,
630
- updated_at: activity.updatedAt,
631
- };
632
- const monthlyCommits = activity.monthlyCommits ?? [];
633
- const primaryStack = typeof parsed.stack === "string" ? parsed.stack : parsed.stack.primary;
634
- const health = healthFromSignals({ repo, monthlyCommits, legacyStatus: parsed.status });
635
- return {
636
- schemaVersion: 0,
637
- slug: parsed.slug,
638
- name: parsed.name,
639
- description: parsed.description,
640
- repoUrl: parsed.repoUrl,
641
- visibility: health.visibility,
642
- cleanupCandidate: health.cleanupCandidate,
643
- staleReason: health.staleReason,
644
- homepageUrl: parsed.homepageUrl,
645
- stack: primaryStack,
646
- stacks: compactArray([...(parsed.stacks ?? [])]),
647
- platforms: parsed.platforms,
648
- distribution: parsed.distribution ?? { channels: [] },
649
- category: parsed.category,
650
- tags: compactArray((parsed.tags ?? [])),
651
- license: parsed.license,
652
- status: health.status,
653
- stars: repo.stargazers_count,
654
- forks: repo.forks_count,
655
- openIssues: repo.open_issues_count,
656
- lastCommitAt: formatDateOnly(repo.pushed_at) ?? null,
657
- labels: parsed.labels ?? [],
658
- lenses: parsed.lenses ?? [],
659
- projectType: parsed.projectType,
660
- difficulty: parsed.difficulty,
661
- codebaseSize: parsed.codebaseSize,
662
- bestFor: parsed.bestFor ?? [],
663
- whyListed: parsed.whyListed ?? [],
664
- caveats: parsed.caveats ?? [],
665
- scores: parsed.scores ?? {},
666
- curation: {
667
- reviewed: false,
668
- },
669
- github: {
670
- repository: repo,
671
- activity: {
672
- monthlyCommits,
673
- totalCommitsKnown: activity.totalCommitsKnown,
674
- contributorsKnown: activity.contributors,
675
- openPullRequests: activity.openPullRequests,
676
- },
677
- sync: {
678
- syncedAt: activity.updatedAt,
679
- source: "legacy-activity-block",
680
- },
681
- },
682
- health: {
683
- status: health.status,
684
- tier: health.tier,
685
- visibility: health.visibility,
686
- cleanupCandidate: health.cleanupCandidate,
687
- staleReason: health.staleReason,
688
- },
689
- tier: health.tier,
690
- };
691
- }
692
- export function validateAppRecord(raw, fileSlug) {
693
- try {
694
- normalizeAppRecord(raw, fileSlug);
695
- return [];
696
- }
697
- catch (err) {
698
- if (err instanceof z.ZodError) {
699
- return err.issues.map((issue) => `${fileSlug}: ${issue.path.join(".") || "(root)"} ${issue.message}`);
700
- }
701
- return [`${fileSlug}: ${err.message}`];
702
- }
703
- }
704
- export function toIndexApp(app) {
705
- return {
706
- slug: app.slug,
707
- name: app.name,
708
- description: app.description,
709
- repoUrl: app.repoUrl,
710
- homepageUrl: app.homepageUrl,
711
- category: app.category,
712
- platforms: app.platforms ?? [],
713
- distribution: app.distribution,
714
- primaryStack: app.stack,
715
- stack: app.stack,
716
- stacks: app.stacks ?? [],
717
- technologies: compactArray([app.stack, ...(app.stacks ?? [])]),
718
- tier: app.health?.tier ?? app.tier,
719
- status: app.health?.status ?? app.status,
720
- visibility: app.health?.visibility ?? "keep",
721
- stars: app.github?.repository?.stargazers_count ?? app.stars,
722
- forks: app.github?.repository?.forks_count,
723
- openIssues: app.github?.repository?.open_issues_count,
724
- license: app.github?.repository?.license?.spdx_id ?? app.license,
725
- lastCommitAt: formatDateOnly(app.github?.repository?.pushed_at ?? app.lastCommitAt),
726
- tags: app.tags ?? [],
727
- };
728
- }
729
653
  //# sourceMappingURL=schema.js.map