@grove-dev/core 0.1.5 → 0.2.5

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
107
+ // ──────────────────────────────────────────────────────────────────────
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({});
83
117
  // ──────────────────────────────────────────────────────────────────────
84
- // GitHub-shaped metadata
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,225 @@ 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
+ /**
277
+ * Effective visibility after any decisions.yml override. For project
278
+ * records the canonical signal lives in `health.visibility`; for
279
+ * resource-hub / ecosystem-map records (which have no `health`
280
+ * block) the top-level `visibility` is the source of truth.
281
+ * Defaults to "keep" so existing YAML files without the field
282
+ * continue to validate.
283
+ */
284
+ visibility: decisionVisibilitySchema.default("keep"),
285
+ });
286
+ // ──────────────────────────────────────────────────────────────────────
287
+ // Blueprint: project-directory — kind: project
406
288
  // ──────────────────────────────────────────────────────────────────────
407
- export const curatedConfigSchema = z.object({
289
+ export const projectRecordSchema = resourceBaseSchema.extend({
290
+ kind: z.literal("project"),
408
291
  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(),
292
+ projectType: projectTypeSchema.optional(),
293
+ stack: z.string().optional(),
294
+ stacks: z.array(z.string()).default([]),
295
+ platforms: z.array(z.string()).default([]),
296
+ difficulty: z.enum(["beginner", "intermediate", "advanced"]).optional(),
297
+ codebaseSize: z.enum(["small", "medium", "large", "huge"]).optional(),
298
+ // Canonical repo URL. Distinct from `links.github`, which is the
299
+ // human-facing link on the page; `repoUrl` is the single source
300
+ // for owner/repo extraction, avatar fallback, and "view repo" CTAs.
412
301
  repoUrl: z.string().url().optional(),
413
- itemLabel: z.string().default("project"),
302
+ // Optional project logo/avatar URL. Falls back to the GitHub
303
+ // owner avatar (when `repoUrl` is a github.com URL) or to the
304
+ // first-2-letters initials placeholder.
305
+ logoUrl: z.string().url().optional(),
306
+ bestFor: z.array(z.string()).default([]),
307
+ whyListed: z.array(z.string()).default([]),
308
+ caveats: z.array(z.string()).default([]),
309
+ distribution: z
310
+ .object({
311
+ channels: z
312
+ .array(z
313
+ .object({
314
+ type: z.string().min(1),
315
+ platform: z.string().optional(),
316
+ label: z.string().optional(),
317
+ url: z.string().url(),
318
+ verified: z.boolean().optional(),
319
+ notes: z.string().optional(),
320
+ })
321
+ .passthrough())
322
+ .default([]),
323
+ })
324
+ .default({ channels: [] }),
325
+ github: z
326
+ .object({
327
+ repository: githubRepositorySchema.optional(),
328
+ languages: z.record(z.string(), z.number()).optional(),
329
+ latestRelease: z.record(z.string(), z.unknown()).nullable().optional(),
330
+ activity: z.record(z.string(), z.unknown()).optional(),
331
+ files: z.record(z.string(), z.boolean()).optional(),
332
+ labels: z.array(z.record(z.string(), z.unknown())).optional(),
333
+ sync: z.record(z.string(), z.unknown()).optional(),
334
+ })
335
+ .passthrough()
336
+ .optional(),
337
+ health: healthBlockSchema.optional(),
338
+ });
339
+ // ──────────────────────────────────────────────────────────────────────
340
+ // Blueprint: resource-hub — kind: resource
341
+ // ──────────────────────────────────────────────────────────────────────
342
+ export const resourceRecordSchema = resourceBaseSchema.extend({
343
+ kind: z.literal("resource"),
344
+ title: z.string().min(1),
345
+ type: resourceTypeSchema,
346
+ topic: z.string().min(1),
347
+ related: z.array(z.string()).default([]), // slugs of related resources
348
+ publishedAt: z.string().optional(),
349
+ author: z.string().optional(),
350
+ });
351
+ // ──────────────────────────────────────────────────────────────────────
352
+ // Blueprint: ecosystem-map — kind: entity
353
+ // ──────────────────────────────────────────────────────────────────────
354
+ export const entityRecordSchema = resourceBaseSchema.extend({
355
+ kind: z.literal("entity"),
356
+ name: z.string().min(1),
357
+ type: entityTypeSchema,
358
+ founded: z.string().optional(),
359
+ location: z.string().optional(),
360
+ members: z.number().int().nonnegative().optional(),
361
+ parent: z.string().optional(), // slug of parent entity
362
+ });
363
+ // ──────────────────────────────────────────────────────────────────────
364
+ // Discriminated union: Resource
365
+ // ──────────────────────────────────────────────────────────────────────
366
+ export const resourceSchema = z.discriminatedUnion("kind", [
367
+ projectRecordSchema,
368
+ resourceRecordSchema,
369
+ entityRecordSchema,
370
+ ]);
371
+ // ──────────────────────────────────────────────────────────────────────
372
+ // Records file (V1: one resource per file)
373
+ // ──────────────────────────────────────────────────────────────────────
374
+ //
375
+ // V1 is intentionally narrow: one YAML file = one resource. The
376
+ // filename (minus `.yml`) is the canonical slug. This keeps PR
377
+ // review clean, removes an entire class of "which slug wins" bugs,
378
+ // and matches what every contributor actually wants to read.
379
+ //
380
+ // The previous "array or { records: [...] }" shape leaked into the
381
+ // parser and forced every consumer to call `unwrapRecords`. The
382
+ // `resourceSchema.parse(raw)` call below is now a direct mapping
383
+ // parse; multi-record files are a V2 feature and will live behind
384
+ // a separate `recordsBundleSchema`.
385
+ export const recordsFileSchema = resourceSchema;
386
+ // ──────────────────────────────────────────────────────────────────────
387
+ // Grove config
388
+ // ──────────────────────────────────────────────────────────────────────
389
+ export const navItemSchema = z.object({
390
+ label: z.string().min(1),
391
+ href: z.string().min(1),
392
+ });
393
+ export const githubIntegrationSchema = z.union([
394
+ z.boolean(),
395
+ z.object({
396
+ metadata: z.boolean().default(false),
397
+ contributors: z.boolean().default(false),
398
+ health: z.boolean().default(false),
399
+ }),
400
+ ]);
401
+ export const themeSchema = z.object({
402
+ primaryColor: z.string().default("#16a34a"),
403
+ radius: z.enum(["none", "soft", "round"]).default("soft"),
404
+ density: z.enum(["compact", "comfortable", "spacious"]).default("comfortable"),
405
+ containerWidth: z.string().default("72rem"),
406
+ });
407
+ export const componentOverrideSchema = z.object({
408
+ Header: z.string().optional(),
409
+ Footer: z.string().optional(),
410
+ Hero: z.string().optional(),
411
+ ItemCard: z.string().optional(),
412
+ DetailHeader: z.string().optional(),
413
+ });
414
+ export const groveConfigSchema = z.object({
415
+ blueprint: blueprintSchema.default("project-directory"),
416
+ site: z.object({
417
+ name: z.string().min(1),
418
+ tagline: z.string().default("A growing community knowledge site."),
419
+ description: z.string().optional(),
420
+ url: z.string().url().optional(),
421
+ repoUrl: z.string().url().optional(),
422
+ }),
423
+ nav: z.array(navItemSchema).default([]),
424
+ facets: z.array(z.string()).default(["category", "tags"]),
425
+ integrations: z
426
+ .object({
427
+ github: githubIntegrationSchema.default(false),
428
+ })
429
+ .default({ github: false }),
430
+ theme: themeSchema.default({
431
+ primaryColor: "#16a34a",
432
+ radius: "soft",
433
+ density: "comfortable",
434
+ containerWidth: "72rem",
435
+ }),
436
+ components: componentOverrideSchema.default({}),
414
437
  paths: z
415
438
  .object({
416
- sourcesDir: z.string().default("sources"),
417
439
  dataDir: z.string().default("data"),
418
440
  contentDir: z.string().default("content"),
419
- appsDir: z.string().default("data/apps"),
441
+ recordsDir: z.string().default("data/records"),
442
+ pagesDir: z.string().default("content/pages"),
443
+ bodiesDir: z.string().default("content/records"),
444
+ publicDir: z.string().default("public"),
420
445
  taxonomyDir: z.string().default("data/taxonomy"),
421
446
  generatedDir: z.string().default("data/generated"),
422
- items: z.string().default("data/items.yml"),
423
447
  health: z.string().default("data/health.yml"),
424
448
  decisions: z.string().default("data/decisions.yml"),
425
449
  overrides: z.string().default("data/overrides.yml"),
426
450
  })
427
451
  .default({
428
- sourcesDir: "sources",
429
452
  dataDir: "data",
430
453
  contentDir: "content",
431
- appsDir: "data/apps",
454
+ recordsDir: "data/records",
455
+ pagesDir: "content/pages",
456
+ bodiesDir: "content/records",
457
+ publicDir: "public",
432
458
  taxonomyDir: "data/taxonomy",
433
459
  generatedDir: "data/generated",
434
- items: "data/items.yml",
435
460
  health: "data/health.yml",
436
461
  decisions: "data/decisions.yml",
437
462
  overrides: "data/overrides.yml",
@@ -440,9 +465,6 @@ export const curatedConfigSchema = z.object({
440
465
  // ──────────────────────────────────────────────────────────────────────
441
466
  // Helpers
442
467
  // ──────────────────────────────────────────────────────────────────────
443
- export function unwrapItems(value) {
444
- return Array.isArray(value) ? value : value.items;
445
- }
446
468
  export function unwrapHealth(value) {
447
469
  return Array.isArray(value) ? value : value.health;
448
470
  }
@@ -452,17 +474,139 @@ export function unwrapDecisions(value) {
452
474
  export function unwrapOverrides(value) {
453
475
  return Array.isArray(value) ? value : value.overrides;
454
476
  }
455
- function compactArray(values) {
456
- return [...new Set((values ?? []).filter(Boolean))];
477
+ /**
478
+ * Parse a single record YAML mapping into a typed Resource. The
479
+ * `kind` field on the YAML selects which blueprint schema to apply.
480
+ */
481
+ export function parseRecordYaml(text, fileSlug) {
482
+ const raw = parse(text) ?? {};
483
+ if (typeof raw !== "object" || Array.isArray(raw)) {
484
+ throw new Error(`${fileSlug}: record file must contain a YAML mapping`);
485
+ }
486
+ return raw;
487
+ }
488
+ export function stringifyRecordYaml(record) {
489
+ return stringify(record, {
490
+ lineWidth: 100,
491
+ singleQuote: false,
492
+ defaultStringType: "PLAIN",
493
+ });
457
494
  }
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);
495
+ export function getOwnerRepoFromUrl(repoUrl) {
496
+ if (!repoUrl)
497
+ return null;
498
+ const m = String(repoUrl).match(/github\.com\/([^/]+)\/([^/?#]+)/);
499
+ if (!m)
500
+ return null;
501
+ return { owner: m[1], repo: m[2].replace(/\.git$/, "") };
465
502
  }
503
+ export function isIndexProject(r) {
504
+ return r.kind === "project";
505
+ }
506
+ export function isIndexResource(r) {
507
+ return r.kind === "resource";
508
+ }
509
+ export function isIndexEntity(r) {
510
+ return r.kind === "entity";
511
+ }
512
+ /**
513
+ * Project a Resource to a stable, search-index-friendly summary
514
+ * object. Strips out blueprint-specific heavy fields and leaves
515
+ * only what the public list/detail UIs need.
516
+ *
517
+ * The return type is the discriminated `IndexRecord` union. The
518
+ * function body uses a single `kind` literal cast per branch
519
+ * because the `record.kind` is a `ResourceKind` (the union of
520
+ * all three) and TypeScript cannot narrow the literal from a
521
+ * runtime check alone. The casts are safe because each branch
522
+ * is gated on the literal string compare.
523
+ */
524
+ export function toIndexRecord(record) {
525
+ const base = {
526
+ slug: record.slug,
527
+ kind: record.kind,
528
+ category: record.category,
529
+ tags: record.tags ?? [],
530
+ links: record.links,
531
+ description: record.description,
532
+ content: record.content,
533
+ curation: record.curation,
534
+ };
535
+ if (record.kind === "project") {
536
+ const r = record;
537
+ return {
538
+ ...base,
539
+ kind: "project",
540
+ name: r.name,
541
+ stack: r.stack,
542
+ stacks: r.stacks ?? [],
543
+ platforms: r.platforms ?? [],
544
+ projectType: r.projectType,
545
+ repoUrl: r.repoUrl,
546
+ logoUrl: r.logoUrl,
547
+ difficulty: r.difficulty,
548
+ codebaseSize: r.codebaseSize,
549
+ bestFor: r.bestFor,
550
+ whyListed: r.whyListed,
551
+ caveats: r.caveats,
552
+ // Health surfaced alongside the record so list/detail UIs can
553
+ // show staleness/curation tier without a second lookup.
554
+ health: r.health,
555
+ // Visibility comes from either the health block (auto-derived
556
+ // from signals) or the curation override (`decisions.yml`).
557
+ // The generate step folds the latter in before serialising,
558
+ // so this is a best-effort projection of the current state.
559
+ visibility: r.health?.visibility ?? "keep",
560
+ github: r.github?.repository
561
+ ? {
562
+ fullName: r.github.repository.full_name,
563
+ stars: r.github.repository.stargazers_count ?? 0,
564
+ forks: r.github.repository.forks_count ?? 0,
565
+ openIssues: r.github.repository.open_issues_count ?? 0,
566
+ language: r.github.repository.language ?? null,
567
+ pushedAt: r.github.repository.pushed_at ?? null,
568
+ archived: Boolean(r.github.repository.archived),
569
+ license: r.github.repository.license?.spdx_id ?? null,
570
+ topics: r.github.repository.topics ?? [],
571
+ }
572
+ : undefined,
573
+ };
574
+ }
575
+ if (record.kind === "resource") {
576
+ const r = record;
577
+ return {
578
+ ...base,
579
+ kind: "resource",
580
+ title: r.title,
581
+ type: r.type,
582
+ topic: r.topic,
583
+ related: r.related,
584
+ publishedAt: r.publishedAt,
585
+ author: r.author,
586
+ // Non-project records store effective visibility at the top
587
+ // level (no `health` block); applyDecision has already merged
588
+ // any decisions.yml override into `r.visibility` by the time
589
+ // we get here.
590
+ visibility: r.visibility,
591
+ };
592
+ }
593
+ // entity
594
+ const e = record;
595
+ return {
596
+ ...base,
597
+ kind: "entity",
598
+ name: e.name,
599
+ type: e.type,
600
+ founded: e.founded,
601
+ location: e.location,
602
+ members: e.members,
603
+ parent: e.parent,
604
+ visibility: e.visibility,
605
+ };
606
+ }
607
+ // ──────────────────────────────────────────────────────────────────────
608
+ // Health signal derivation
609
+ // ──────────────────────────────────────────────────────────────────────
466
610
  function daysSince(value) {
467
611
  if (!value)
468
612
  return Infinity;
@@ -472,7 +616,7 @@ function daysSince(value) {
472
616
  return (Date.now() - d.valueOf()) / 86_400_000;
473
617
  }
474
618
  export function healthFromSignals(params) {
475
- const { repo, monthlyCommits, legacyStatus } = params;
619
+ const { repo, monthlyCommits } = params;
476
620
  const stars = repo?.stargazers_count ?? 0;
477
621
  const lastCommitAt = repo?.pushed_at;
478
622
  const archived = Boolean(repo?.archived);
@@ -503,10 +647,13 @@ export function healthFromSignals(params) {
503
647
  ? "listed"
504
648
  : "experimental";
505
649
  return {
506
- status: legacyStatus ?? status,
650
+ status,
507
651
  tier,
508
652
  visibility: tier === "hidden" ? "hide" : "keep",
509
- cleanupCandidate: status === "stale" || status === "archived" || status === "inactive" || status === "unavailable",
653
+ cleanupCandidate: status === "stale" ||
654
+ status === "archived" ||
655
+ status === "inactive" ||
656
+ status === "unavailable",
510
657
  staleReason: status === "inactive"
511
658
  ? "no_commits_24_months"
512
659
  : status === "stale"
@@ -518,212 +665,4 @@ export function healthFromSignals(params) {
518
665
  : null,
519
666
  };
520
667
  }
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
668
  //# sourceMappingURL=schema.js.map