merge: task 04-content-schema
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
# Task 04 — Content collection schema
|
# Task 04 — Content collection schema
|
||||||
|
|
||||||
**Agent**: `content-i18n-migrator` · **Model**: MiniMax-M3
|
**Agent**: `content-i18n-migrator` · **Model**: MiniMax-M3 **Depends on**: 01 ·
|
||||||
**Depends on**: 01 · **Parallel with**: 02, 03 · **Blocks**: 05, 06
|
**Parallel with**: 02, 03 · **Blocks**: 05, 06 **Worktree**:
|
||||||
**Worktree**: `.agents/scripts/worktree.sh start 04 content-schema`
|
`.agents/scripts/worktree.sh start 04 content-schema`
|
||||||
|
|
||||||
## Goal
|
## 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
|
**Both required.** A missing `pt` must fail the build — silent English
|
||||||
fallback is how bilingual sites quietly become monolingual.
|
fallback is how bilingual sites quietly become monolingual.
|
||||||
2. Collections:
|
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/`
|
- `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:
|
3. **Make the language-switching decision** and record it here:
|
||||||
- *client-side swap* — matches today, no URL change, both languages in the payload. **Recommended.**
|
- _client-side swap_ — matches today, no URL change, both languages in the
|
||||||
- *route-based `/en/` `/pt/`* — better SEO, changes every existing URL, needs redirects.
|
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 `<html lang>` 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 `<html lang>` 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.
|
||||||
|
|
||||||
|
`<html lang>` must still track the active language regardless of how the strings
|
||||||
|
reach the page.
|
||||||
|
|
||||||
## Done when
|
## Done when
|
||||||
|
|
||||||
- [ ] `astro check` passes
|
- [x] `astro check` passes — `Result (22 files): 0 errors, 0 warnings, 2 hints`
|
||||||
- [ ] A deliberately missing `pt` field fails the build (prove it, then revert)
|
(the two hints are pre-existing `document.execCommand` deprecations in
|
||||||
- [ ] The language decision is written down here with its reason
|
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, `<html lang>` tracks active language.
|
||||||
|
|
||||||
## Do not
|
## Do not
|
||||||
|
|
||||||
Do not move any content yet. Schema only — tasks 05 and 06 fill it, and they
|
Do not move any content yet. Schema only — tasks 05 and 06 fill it, and they run
|
||||||
run in parallel against the shape you define.
|
in parallel against the shape you define.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user