-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcontent.config.ts
More file actions
429 lines (395 loc) · 17.7 KB
/
Copy pathcontent.config.ts
File metadata and controls
429 lines (395 loc) · 17.7 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
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
import { readdirSync, existsSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import { defineCollection } from "astro:content";
import type { Loader } from "astro/loaders";
import { z } from "astro/zod";
import { parse as parseYaml } from "yaml";
import {
beginAbbrScope,
mdToInline,
mdToBlock,
mdToInlineArray,
mdToBlockArray,
} from "./lib/markdown-pipeline.mjs";
import { LEVEL_DIFFICULTY_BY_EMOJI } from "./lib/level-constants.mjs";
import { parseDeadline } from "./lib/deadline.mjs";
import {
buildLevelMetaDescription,
buildServicesStepBody,
} from "./lib/adventure-derive.mjs";
import { COMMUNITY_URL } from "./lib/site";
import { MONTHS } from "./lib/challenges";
import { EMOJI_TO_ICON } from "./lib/adventure-icons";
import { creditIntegrityError } from "./lib/adventure-credit";
import type { AdventureLevel, AdventureRewards } from "./data/adventures/types";
// Adventure YAML lives in this app's own data dir (src/data/adventures),
// resolved from this file's location (src/content.config.ts).
const ADVENTURES_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "data/adventures");
const CODESPACES_BASE = "https://codespaces.new/off-on-dev/open-source-challenges";
const DEFAULT_REWARDS_ELIGIBILITY =
"Complete all levels and post your solution in the community before the deadline to be eligible.";
const DEFAULT_REWARDS_RANKING_NOTE =
"Ranking is determined by total points across all three levels. Points per level are awarded" +
" by submission order within the active week (100 for the first valid solution, 95 for the" +
" second, and so on; late submissions still earn 60).";
const DEFAULT_REWARDS_RANKING_RULES_PATH = "/t/about-the-challenges-category/16";
const DIFFICULTY = z.enum(["Beginner", "Intermediate", "Expert"]);
// --- Zod schema (translated from schemas/adventure.schema.json) ---
// .strict() mirrors additionalProperties:false and preserves the ajv validation
// gate: unknown fields fail the build (via `astro sync` / `astro build`).
const contributorSchema = z
.object({
name: z.string(),
url: z.url().optional(),
about: z.string().optional(),
discourse_username: z.string().optional(),
})
.strict();
const rewardsSchema = z
.object({
deadline: z.string(),
eligibility: z.string().optional(),
tiers: z.array(z.object({ label: z.string(), description: z.string() }).strict()),
ranking_note: z.string().optional(),
ranking_rules_url: z.string().optional(),
})
.strict();
const upcomingLevelSchema = z
.object({ level: z.string().optional(), name: z.string(), difficulty: DIFFICULTY })
.strict();
const toolboxItemSchema = z
.object({ name: z.string(), description: z.string(), url: z.url().optional() })
.strict();
const serviceSchema = z
.object({
name: z.string(),
port: z.union([z.string(), z.number()]).optional(),
url: z.string().optional(),
credentials: z.string().optional(),
description: z.string(),
internal: z.boolean().optional(),
})
.strict();
const howToPlayStepSchema = z
.object({ id: z.string().optional(), title: z.string(), content: z.string() })
.strict();
const verificationSchema = z.object({ command: z.string(), description: z.string() }).strict();
const helpfulLinkSchema = z
.object({ title: z.string(), url: z.url(), description: z.string().optional() })
.strict();
const levelSchema = z
.object({
level: z.string(),
name: z.string().optional(),
title: z.string().optional(),
emoji: z.string().optional(),
difficulty: DIFFICULTY.optional(),
topics: z.array(z.string()),
learnings: z.array(z.string()).min(1).optional(),
what_you_learn: z.array(z.string()).min(1).optional(),
devcontainer: z.string(),
codespaces_machine: z.enum(["4core"]).optional(),
discussion_url: z.string().optional(),
community_url: z.string().optional(),
deadline: z.string().optional(),
hook: z.string().optional(),
summary: z.string().optional(),
intro: z.array(z.string()).optional(),
backstory: z.array(z.string()).optional(),
objective: z.array(z.string()),
audience: z.string().optional(),
estimated_time: z.string().optional(),
scenario: z.string().optional(),
architecture: z.array(z.string()).optional(),
architecture_diagram: z.string().optional(),
diagram_alt: z.string().optional(),
architecture_ascii: z.string().optional(),
toolbox: z.array(toolboxItemSchema),
services: z.array(serviceSchema).optional(),
how_to_play: z.array(howToPlayStepSchema),
verification: verificationSchema,
helpful_links: z.array(helpfulLinkSchema).optional(),
// Length is warned about in renderLevel, not enforced: see warnIfMetaDescriptionLong.
meta_description: z.string().optional(),
solved_count: z.number().int().optional(),
top_players: z
.array(z.object({ username: z.string(), count: z.number().int() }).strict())
.optional(),
contributor: contributorSchema.optional(),
})
.strict()
.refine((l) => l.name || l.title, { message: "level needs name or title" })
.refine((l) => l.difficulty || (l.emoji && LEVEL_DIFFICULTY_BY_EMOJI[l.emoji as keyof typeof LEVEL_DIFFICULTY_BY_EMOJI]), {
message: "level needs difficulty or a 🟢/🟡/🔴 emoji",
})
.refine((l) => l.learnings || l.what_you_learn, {
message: "level needs learnings or what_you_learn",
});
// --- Resolvers ---
const META_DESCRIPTION_MAX = 160;
// Over-length meta descriptions are an SEO smell, not a content error: search
// engines truncate the tail and the page still renders correctly. Warn so the
// sync PR gets refined before release, but never fail `astro sync` (and with it
// the whole sync-adventure workflow) over a description a reviewer can trim.
function warnIfMetaDescriptionLong(value: string | undefined, where: string): void {
if (value && value.length > META_DESCRIPTION_MAX) {
console.warn(
`[content] ${where}: meta_description is ${value.length} chars, over the ${META_DESCRIPTION_MAX}-char SEO limit. Search engines will truncate it.`,
);
}
}
function requireEither(a: string | undefined | null, b: string | undefined | null, field: string): string {
const value = a ?? b;
if (value != null && value !== "") return value;
throw new Error(`Content validation error: ${field} is required but was not provided`);
}
function resolveCodespacesUrl(devcontainer: string, machine?: string): string {
const path = `.devcontainer/${devcontainer}/devcontainer.json`;
const encoded = encodeURIComponent(path);
const machineParam = machine === "4core" ? "&machine=standardLinux32gb" : "";
return `${CODESPACES_BASE}?devcontainer_path=${encoded}&quickstart=1${machineParam}`;
}
function resolveDiscussionUrl(raw?: string): string {
const value = raw ?? "";
if (!value) return "";
if (value.startsWith("http")) return value;
const path = value.startsWith("/") ? value : `/${value}`;
return `${COMMUNITY_URL}${path}`;
}
function resolveCommunityPath(url: string): string {
if (url.startsWith("http")) return url;
const path = url.startsWith("/") ? url : `/${url}`;
return `${COMMUNITY_URL}${path}`;
}
function assertDifficulty(
d: string | undefined,
levelId: string,
): asserts d is AdventureLevel["difficulty"] {
if (!d || !["Beginner", "Intermediate", "Expert"].includes(d)) {
throw new Error(
`[content] level "${levelId}" has invalid difficulty "${d ?? "(missing!)"}": ` +
`expected Beginner, Intermediate, or Expert. ` +
`YAML must supply a difficulty field or a recognised emoji.`,
);
}
}
// AdventureLevel (from data/adventures/types.ts) is the single source of truth
// for the rendered level shape. renderLevel's return type is checked against it,
// so the two cannot drift silently.
async function renderLevel(level: z.infer<typeof levelSchema>, slug: string): Promise<AdventureLevel> {
warnIfMetaDescriptionLong(level.meta_description, `${slug} → level "${level.level}"`);
const difficulty = level.difficulty ?? (level.emoji ? LEVEL_DIFFICULTY_BY_EMOJI[level.emoji as keyof typeof LEVEL_DIFFICULTY_BY_EMOJI] : undefined);
const learnings = level.learnings ?? level.what_you_learn ?? [];
const intro = level.intro ?? (level.summary ? [level.summary] : undefined);
// Inject an "Explore the UIs" step from services at index 1.
const steps: { title: string; content: string }[] = [...level.how_to_play];
const servicesBody = buildServicesStepBody(level.services);
if (servicesBody) steps.splice(1, 0, { title: "Explore the UIs", content: servicesBody });
const [
learningsHtml,
audienceHtml,
objectiveHtml,
introHtml,
backstoryHtml,
scenarioHtml,
architectureHtml,
contributorAboutHtml,
toolbox,
howToPlay,
] = await Promise.all([
mdToInlineArray(learnings),
level.audience ? mdToInline(level.audience) : Promise.resolve(null),
mdToInlineArray(level.objective),
intro ? mdToInlineArray(intro) : Promise.resolve(null),
level.backstory ? mdToInlineArray(level.backstory) : Promise.resolve(null),
level.scenario ? mdToBlock(level.scenario) : Promise.resolve(null),
level.architecture ? mdToBlockArray(level.architecture) : Promise.resolve(null),
level.contributor?.about ? mdToInline(level.contributor.about) : Promise.resolve(null),
Promise.all(
level.toolbox.map(async (t) => ({ ...t, description: await mdToInline(t.description) })),
),
Promise.all(
steps.map(async (s) => ({
title: await mdToInline(s.title),
content: await mdToBlock(s.content),
})),
),
]);
assertDifficulty(difficulty, level.level);
return {
id: level.level,
name: requireEither(level.name, level.title, "level name/title"),
difficulty,
topics: level.topics,
learnings: learningsHtml,
codespacesUrl: resolveCodespacesUrl(level.devcontainer, level.codespaces_machine),
discussionUrl: resolveDiscussionUrl(level.discussion_url ?? level.community_url),
...(level.deadline ? { deadline: parseDeadline(level.deadline) } : {}),
...(level.hook ? { hook: level.hook } : {}),
...(introHtml ? { intro: introHtml } : {}),
...(backstoryHtml ? { backstory: backstoryHtml } : {}),
objective: objectiveHtml,
...(audienceHtml ? { audience: audienceHtml } : {}),
...(level.estimated_time ? { estimatedTime: level.estimated_time } : {}),
...(scenarioHtml ? { scenario: scenarioHtml } : {}),
...(architectureHtml ? { architecture: architectureHtml } : {}),
// architectureDiagram is a filename; the level page resolves it against the
// src/assets/diagrams glob (import.meta.glob) to a hashed, emitted asset URL.
...(level.architecture_diagram ? { architectureDiagram: level.architecture_diagram } : {}),
...(level.diagram_alt ? { diagramAlt: level.diagram_alt } : {}),
...(level.architecture_ascii ? { architectureAscii: level.architecture_ascii } : {}),
toolbox,
howToPlay,
...(level.helpful_links ? { helpfulLinks: level.helpful_links } : {}),
verification: level.verification,
metaDescription: level.meta_description || buildLevelMetaDescription(level),
...(level.contributor ? { contributor: { name: level.contributor.name, url: level.contributor.url, ...(level.contributor.discourse_username ? { discourseUsername: level.contributor.discourse_username } : {}), ...(contributorAboutHtml ? { aboutHtml: contributorAboutHtml } : {}) } } : {}),
};
}
async function renderRewards(
rewards: z.infer<typeof rewardsSchema>,
): Promise<AdventureRewards> {
const eligibility = await mdToInline(rewards.eligibility ?? DEFAULT_REWARDS_ELIGIBILITY);
const rankingNote = await mdToInline(rewards.ranking_note ?? DEFAULT_REWARDS_RANKING_NOTE);
const tiers = await Promise.all(
rewards.tiers.map(async (t) => ({ label: t.label, description: await mdToInline(t.description) })),
);
return {
deadline: rewards.deadline === "TODO" ? "" : (parseDeadline(rewards.deadline) ?? ""),
eligibility,
tiers,
rankingNote,
rankingRulesUrl: resolveCommunityPath(
rewards.ranking_rules_url ?? DEFAULT_REWARDS_RANKING_RULES_PATH,
),
};
}
// Custom loader: parses YAML with the `yaml` package (YAML 1.2 core), matching
// the generator. Astro's built-in glob() YAML parser auto-casts unquoted ISO
// timestamps to Date objects, corrupting deadline fields; this avoids that and
// gives digest-gated incremental rendering.
function adventuresLoader(): Loader {
return {
name: "adventures-loader",
async load({ store, parseData, generateDigest, watcher }) {
const seen = new Set<string>();
let entries;
try {
entries = readdirSync(ADVENTURES_DIR, { withFileTypes: true });
} catch (err) {
throw new Error(
`[adventures-loader] Cannot read adventures directory "${ADVENTURES_DIR}": build aborted to prevent deploying a site with no adventure pages.`,
{ cause: err },
);
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const yamlPath = resolve(ADVENTURES_DIR, entry.name, "adventure.yaml");
if (!existsSync(yamlPath)) continue;
seen.add(entry.name);
const raw = readFileSync(yamlPath, "utf8");
const digest = generateDigest(raw);
// Digest is over the YAML only, so a change to the RENDERING code
// (markdown-pipeline.mjs / adventure-derive.mjs) does not invalidate a
// persisted store. CI is unaffected (fresh npm ci → empty store → full
// render); locally, clear node_modules/.astro/data-store.json (or .astro)
// after editing the pipeline to force a re-render.
watcher?.add(yamlPath);
if (store.get(entry.name)?.digest === digest) continue; // unchanged: skip re-render
// Scope this entry's abbreviation IDs so they cannot collide with
// another adventure's on pages that render several (home, /challenges/).
beginAbbrScope(entry.name);
const data = await parseData({ id: entry.name, data: parseYaml(raw) });
if (data.slug !== entry.name) {
throw new Error(
`Adventure "${entry.name}": YAML slug "${data.slug}" must match the directory name. Rename one or the other.`,
);
}
store.set({ id: entry.name, data, digest });
}
// Drop entries whose YAML was deleted.
for (const id of [...store.keys()]) if (!seen.has(id)) store.delete(id);
},
};
}
const adventures = defineCollection({
loader: adventuresLoader(),
schema: z
.object({
slug: z.string().regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$/),
title: z.string().optional(),
name: z.string().optional(),
emoji: z.string().optional(),
icon: z.string().optional(),
month: z.string().regex(/^[A-Z]{3} \d{4}$/).refine(
(m) => MONTHS.includes(m.slice(0, 3)),
{ message: "month abbreviation must be one of " + MONTHS.join(", ") },
),
story: z.string().optional(),
tags: z.array(z.string()).min(1),
contributor: contributorSchema.optional(),
community_category_id: z.number().int().optional(),
// Length is warned about in the transform, not enforced: see warnIfMetaDescriptionLong.
meta_description: z.string(),
backstory: z.array(z.string()).optional(),
overview: z.array(z.string()).optional(),
rewards: rewardsSchema.optional(),
upcoming_levels: z.array(upcomingLevelSchema).optional(),
levels: z.array(levelSchema).min(1),
})
.strict()
.refine((d) => d.title || d.name, { message: "adventure needs title or name" })
// Levels may only name their own builder on an adventure that names a designer.
// Shared with the render path so the rule has one definition.
.superRefine((d, ctx) => {
const message = creditIntegrityError(d);
if (message) ctx.addIssue({ code: "custom", message, path: ["contributor"] });
})
.transform(async (data) => {
warnIfMetaDescriptionLong(data.meta_description, `adventure "${data.slug}"`);
const title = requireEither(data.title, data.name, "adventure title/name");
const story =
data.story ?? data.meta_description ?? (data.backstory && data.backstory.length > 0 ? data.backstory[0] : "");
const icon = data.icon ?? (data.emoji ? EMOJI_TO_ICON[data.emoji as keyof typeof EMOJI_TO_ICON] : undefined);
const [storyHtml, aboutHtml, backstoryHtml, levels, rewards] = await Promise.all([
mdToInline(story),
data.contributor?.about ? mdToInline(data.contributor.about) : Promise.resolve(null),
data.backstory ? mdToInlineArray(data.backstory) : Promise.resolve(null),
Promise.all(data.levels.map((level) => renderLevel(level, data.slug))),
data.rewards ? renderRewards(data.rewards) : Promise.resolve(null),
]);
return {
slug: data.slug,
title,
month: data.month,
story: storyHtml,
metaDescription: data.meta_description,
tags: data.tags,
...(icon ? { icon } : {}),
...(data.contributor
? {
contributor: {
name: data.contributor.name,
url: data.contributor.url,
aboutHtml: aboutHtml ?? undefined,
...(data.contributor.discourse_username ? { discourseUsername: data.contributor.discourse_username } : {}),
},
}
: {}),
...(backstoryHtml ? { backstory: backstoryHtml } : {}),
...(data.overview ? { overview: data.overview } : {}),
...(rewards ? { rewards } : {}),
...(data.upcoming_levels
? {
upcomingLevels: data.upcoming_levels.map((u) => ({
name: u.name,
difficulty: u.difficulty,
})),
}
: {}),
levels,
};
}),
});
export const collections = { adventures };