From b554f87d512b4fa2a898906e2dc04b126e6917db Mon Sep 17 00:00:00 2001 From: Marcos Paulo Date: Sat, 5 Sep 2026 02:58:14 +0000 Subject: [PATCH] feat(content): define bilingual content collection schema Add src/content/config.ts with strict { en, pt } schema and eight typed collections matching the shapes in app.js and skills-review/catalog.js: phases, providers, efforts, skillSources, handsOnPrompts, skillInstallPrompts, chapters, reviews. Both locales are required on every localized field. A deliberately missing pt fails the build with InvalidContentEntryDataError, proved with a probe entry and reverted. Silent English fallback is what turns a bilingual site monolingual; the schema must not allow it. Did not move any content yet. Tasks 05 and 06 fill the entries against the shape defined here, in parallel. Also records the language-switching decision in the task brief: client-side swap, both locales in the payload, html lang tracks the active language. Behaviour parity, schema fit, and tiny payload size beat the SEO upside of route-based i18n for this site. astro check: 0 errors, 0 warnings. npm run verify: green. No assertion count change. Co-Authored-By: Claude Code --- .../astro-refactor/task-04-content-schema.md | 61 +++++-- src/content/config.ts | 164 ++++++++++++++++++ 2 files changed, 213 insertions(+), 12 deletions(-) create mode 100644 src/content/config.ts diff --git a/plans/astro-refactor/task-04-content-schema.md b/plans/astro-refactor/task-04-content-schema.md index b65472b..d9de3e0 100644 --- a/plans/astro-refactor/task-04-content-schema.md +++ b/plans/astro-refactor/task-04-content-schema.md @@ -1,8 +1,8 @@ # Task 04 — Content collection schema -**Agent**: `content-i18n-migrator` · **Model**: MiniMax-M3 -**Depends on**: 01 · **Parallel with**: 02, 03 · **Blocks**: 05, 06 -**Worktree**: `.agents/scripts/worktree.sh start 04 content-schema` +**Agent**: `content-i18n-migrator` · **Model**: MiniMax-M3 **Depends on**: 01 · +**Parallel with**: 02, 03 · **Blocks**: 05, 06 **Worktree**: +`.agents/scripts/worktree.sh start 04 content-schema` ## Goal @@ -18,20 +18,57 @@ Typed collections that make a missing translation a build error. **Both required.** A missing `pt` must fail the build — silent English fallback is how bilingual sites quietly become monolingual. 2. Collections: - - `guide` — `phases`, `modelGuide`, `skillSources`, `handsOnPrompts`, `skillInstallPrompts` from `app.js` + - `guide` — `phases`, `modelGuide`, `skillSources`, `handsOnPrompts`, + `skillInstallPrompts` from `app.js` - `chapters` — copy for `/models/`, `/agents/`, `/skills/`, `/summary/` - - `reviews` — the 24 entries: `id`, `author`, `title`, `status`, `focus`, `wins[]`, `improve[]`, `extras`, `improved` + - `reviews` — the 24 entries: `id`, `author`, `title`, `status`, `focus`, + `wins[]`, `improve[]`, `extras`, `improved` 3. **Make the language-switching decision** and record it here: - - *client-side swap* — matches today, no URL change, both languages in the payload. **Recommended.** - - *route-based `/en/` `/pt/`* — better SEO, changes every existing URL, needs redirects. + - _client-side swap_ — matches today, no URL change, both languages in the + payload. **Recommended.** + - _route-based `/en/` `/pt/`_ — better SEO, changes every existing URL, needs + redirects. + +**Decision (2026-09-05, task 04):** **client-side swap.** Both locales ship in +the payload, the existing toggle swaps text and `` in place, and +nothing about today's URLs changes. Reasons: + +- **Behaviour parity.** The site today is a client-side swap. Choosing anything + else means changing every URL that anyone has shared, plus adding redirects + for `/full-guide/`, `/skills-review/`, and every chapter page. The cost is + paid once at migration and the benefit is invisible to existing visitors. +- **Content size.** ~50 bilingual keys in `app.js` plus 24 review entries. + Shipping both locales in the payload is a few KB on top of what already loads + — negligible compared to the JS the site ships today. +- **Schema fit.** The `{ en, pt }` shape the existing code already uses maps + one-to-one onto the `localized = z.object({ en, pt })` schema in this task. + Route-based i18n would require a different schema (entries per locale) and + force every consumer to pick a locale at the call site. +- **SEO is a known trade-off, not a bug.** Search engines see the active + language in `` and the toggle is reachable on every page. Crawlers + that index only one language will index the rendered one — same as today. + +If a future task decides the SEO trade-off is no longer acceptable, the schema +change is local: split each entry by locale, switch the collection loader, and +add redirects. The decision is reversible. + +`` must still track the active language regardless of how the strings +reach the page. ## Done when -- [ ] `astro check` passes -- [ ] A deliberately missing `pt` field fails the build (prove it, then revert) -- [ ] The language decision is written down here with its reason +- [x] `astro check` passes — `Result (22 files): 0 errors, 0 warnings, 2 hints` + (the two hints are pre-existing `document.execCommand` deprecations in + vanilla JS, not from this schema) +- [x] A deliberately missing `pt` field fails the build — proved with + `src/content/phases/probe.json` (omitted `copy.pt`). Astro raised + `InvalidContentEntryDataError: phases → probe data does not match collection schema. copy.pt: Required`. + Reverted. +- [x] The language decision is written down here with its reason — see "Decision + (2026-09-05, task 04)" above: **client-side swap**, both locales in the + payload, `` tracks active language. ## Do not -Do not move any content yet. Schema only — tasks 05 and 06 fill it, and they -run in parallel against the shape you define. +Do not move any content yet. Schema only — tasks 05 and 06 fill it, and they run +in parallel against the shape you define. diff --git a/src/content/config.ts b/src/content/config.ts new file mode 100644 index 0000000..1ddd601 --- /dev/null +++ b/src/content/config.ts @@ -0,0 +1,164 @@ +// Bilingual content schema for the Astro migration. +// +// Both `en` and `pt` are required on every localized field. A missing `pt` +// must fail the build — silent English fallback is how a bilingual site +// quietly becomes monolingual. Do not change `localized` to make a field +// optional without an explicit reason in a code review. +// +// Layout follows the brief in plans/astro-refactor/task-04-content-schema.md: +// - phases / providers / efforts / skillSources / handsOnPrompts / +// skillInstallPrompts — guide content carried over from app.js +// - chapters — copy for /models/, /agents/, /skills/, /summary/ +// - reviews — the 24 entries from skills-review/catalog.js +// +// This file is schema only. Tasks 05 and 06 fill the entries; the content +// migrator agent owns this file end-to-end. + +import { defineCollection, z } from 'astro:content'; + +// One localized field. `en` and `pt` are mandatory strings; no nullish, +// no default. A missing `pt` is a build error, not a silent fallback. +const localized = z.object({ en: z.string(), pt: z.string() }); + +// Phases from `phases` in app.js: plan / build / review. Each phase has a +// model recommendation, title, prose, and a code-flavoured summary line. +const phases = defineCollection({ + type: 'data', + schema: z.object({ + id: z.string(), + model: localized, + title: localized, + copy: localized, + code: localized, + }), +}); + +// Providers from `modelGuide.providers` in app.js: openai / claude / gemini. +// Tiers are a 3-tuple of [kind label, name, note-localized]. +const providers = defineCollection({ + type: 'data', + schema: z.object({ + id: z.string(), + label: z.string(), + source: z.string().url(), + title: localized, + copy: localized, + tiers: z.array(z.tuple([z.string(), z.string(), localized])), + config: z.string(), + }), +}); + +// Efforts from `modelGuide.efforts` in app.js: low / medium / high. Each +// effort carries three lines per locale (heading, body, code-line). +const efforts = defineCollection({ + type: 'data', + schema: z.object({ + id: z.string(), + en: z.array(z.string()), + pt: z.array(z.string()), + }), +}); + +// Skill source URLs from `skillSources` in app.js: pinned commit URLs for +// each public skill this site references. +const skillSources = defineCollection({ + type: 'data', + schema: z.object({ + id: z.string(), + url: z.string().url(), + }), +}); + +// Hands-on prompts from `handsOnPrompts` in app.js: per-locale basic and +// skills variants for the Tiny Tasks exercise. +const handsOnPrompts = defineCollection({ + type: 'data', + schema: z.object({ + id: z.string(), + en: z.object({ basic: z.string(), skills: z.string() }), + pt: z.object({ basic: z.string(), skills: z.string() }), + }), +}); + +// Skill install prompts from `skillInstallPrompts` in app.js: per-locale +// install instructions that name the pinned commits and the safety rules. +const skillInstallPrompts = defineCollection({ + type: 'data', + schema: z.object({ + id: z.string(), + en: z.string(), + pt: z.string(), + }), +}); + +// Chapter copy for /models/, /agents/, /skills/, /summary/. The body of +// each page composes one or more sections; the field names mirror what the +// existing HTML already says aloud (eyebrow / lede / card titles). +const chapters = defineCollection({ + type: 'data', + schema: z.object({ + id: z.string(), + eyebrow: localized, + title: localized, + lede: localized, + cards: z + .array( + z.object({ + label: localized, + title: localized, + copy: localized, + }), + ) + .optional(), + sections: z + .array( + z.object({ + eyebrow: localized.optional(), + title: localized, + copy: localized.optional(), + steps: z + .array( + z.object({ + label: localized, + copy: localized, + }), + ) + .optional(), + }), + ) + .optional(), + }), +}); + +// Review desk entries from skills-review/catalog.js + +// skills-review/submitted-catalog.js: 24 in total. `improved` is Markdown +// source text, kept as a string here so the diff view that compares +// original-vs-improved still has the raw text available. When the +// markdown render path lands, this can become a body field without +// changing the entry shape. +const reviews = defineCollection({ + type: 'data', + schema: z.object({ + id: z.string(), + author: z.string(), + path: z.string(), + title: z.string(), + status: z.string(), + focus: z.string(), + wins: z.array(z.string()), + improve: z.array(z.string()), + extras: z.string(), + improved: z.string(), + }), +}); + +export const collections = { + phases, + providers, + efforts, + skillSources, + handsOnPrompts, + skillInstallPrompts, + chapters, + reviews, +};