From 64efc91392863c40bf121b914139f3bc04c9e821 Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Wed, 17 Jun 2026 10:20:40 +0200 Subject: [PATCH] feat: add valibot --- .../specs/2026-06-17-plans-library-design.md | 10 +- package.json | 1 + pnpm-lock.yaml | 15 +++ src/composables/useMacroplan.test.ts | 16 ++- src/composables/useMacroplan.ts | 34 +++-- src/model/parse.ts | 123 +++++++++++------- 6 files changed, 130 insertions(+), 69 deletions(-) diff --git a/docs/superpowers/specs/2026-06-17-plans-library-design.md b/docs/superpowers/specs/2026-06-17-plans-library-design.md index b5b1667..e346f2c 100644 --- a/docs/superpowers/specs/2026-06-17-plans-library-design.md +++ b/docs/superpowers/specs/2026-06-17-plans-library-design.md @@ -15,7 +15,7 @@ This adds the library: keep several named Macroplans, switch between them, creat **In:** - Multiple named Macroplans persisted in localStorage. - A header dropdown to switch the active plan. -- Create a new plan (seeded from the bundled sample). +- Create a new plan (a blank page; the bundled sample only seeds a genuinely empty library). - Delete the active plan (with a confirm). - Download the active plan's source as `.toml`. - Migration from the legacy single-key store. @@ -83,11 +83,11 @@ Exposes: | `error` | `ComputedRef` | Active plan's current parse error. | | `plans` | `ComputedRef<{ id, name }[]>` | For the switcher. | | `activeId` | `Ref` | Current selection. | -| `newPlan()` | → `void` | Append a sample-seeded plan and switch to it. | +| `newPlan()` | → `void` | Append a **blank** plan and switch to it. The sample is reserved for a genuinely empty library (first run / delete-the-last), which the ≥1-plan invariant means `newPlan` never sees. | | `deletePlan(id)` | → `void` | Remove; if it was active, re-point `activeId` to the **preceding** plan in the list (or the new first plan if it was at index 0); if it was the last remaining plan, seed a fresh sample (the invariant). | | `selectPlan(id)` | → `void` | Switch the active plan and **reset `lastGood`** so a broken target never shows the previous plan's render. | -`resetToSample` is **removed** — `newPlan()` (seeded from the sample) replaces it. +`resetToSample` is **removed** — `newPlan()` (a blank page) replaces it. ### `PlanSwitcher.vue` (new) — presentational @@ -118,7 +118,7 @@ edit TOML ─► source (active plan) ─► autosave whole library ─► local └─► parse ─► plan/error ─► refresh active plan's cached name (on valid title) switch ─► selectPlan(id) ─► activeId changes ─► source rebinds, lastGood reset ─► re-parse ─► render -new ─► newPlan() ─► append sample plan ─► selectPlan(newId) +new ─► newPlan() ─► append blank plan ─► selectPlan(newId) delete ─► confirm ─► deletePlan(activeId) ─► re-point active to preceding plan (or re-seed if last) ─► render toml ─► downloadSource(source, sourceFilename(title)) ─► browser download ``` @@ -136,7 +136,7 @@ toml ─► downloadSource(source, sourceFilename(title)) ─► browser downl - No storage → library seeds one sample plan, active. - Legacy `macroplan:source` present → migrates to a one-plan library (name = parsed title), legacy key removed. - Corrupt `macroplan:library` → seeds fresh sample (no throw). -- `newPlan()` appends a sample plan and makes it active. +- `newPlan()` appends a **blank** plan and makes it active (the sample only seeds an empty library). - `deletePlan(active)` removes it and re-points `activeId` to a remaining plan. - `deletePlan` of the **last** plan re-seeds a fresh sample (invariant holds). - Editing `source` updates the active entry and refreshes its cached `name` on a valid title; a broken edit keeps the last-good `name` and render. diff --git a/package.json b/package.json index 6df183d..aa8857f 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "html-to-image": "^1.11.13", "shiki": "^4.2.0", "smol-toml": "^1.6.1", + "valibot": "^1.4.1", "vue": "^3.5.34" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ba8dad5..6ee1c45 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: smol-toml: specifier: ^1.6.1 version: 1.6.1 + valibot: + specifier: ^1.4.1 + version: 1.4.1(typescript@6.0.3) vue: specifier: ^3.5.34 version: 3.5.38(typescript@6.0.3) @@ -960,6 +963,14 @@ packages: unist-util-visit@5.1.0: resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + valibot@1.4.1: + resolution: {integrity: sha512-klCmFTz2jeDluy9RwX+F884TCiogtdBJ/YaxSx1EOBYXa3NXNWj8kR1jjN8rzluwojJVWWaHJ4r1U5LfICnM3g==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + vfile-message@4.0.3: resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} @@ -1966,6 +1977,10 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 + valibot@1.4.1(typescript@6.0.3): + optionalDependencies: + typescript: 6.0.3 + vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3 diff --git a/src/composables/useMacroplan.test.ts b/src/composables/useMacroplan.test.ts index 6271589..1157f15 100644 --- a/src/composables/useMacroplan.test.ts +++ b/src/composables/useMacroplan.test.ts @@ -34,6 +34,18 @@ describe('useMacroplan — load & migration', () => { expect(m.source.value).toBe(SAMPLE_PLAN) }) + it('falls back to a fresh sample when the stored library shape is malformed', () => { + // Valid JSON, wrong shape: a plan missing its `source` — would have slipped + // through the old blind `as Library` cast. + localStorage.setItem( + LIB_KEY, + JSON.stringify({ version: 1, activeId: 'x', plans: [{ id: 'x', name: 'Bad' }] }), + ) + const m = useMacroplan() + expect(m.plans.value).toHaveLength(1) + expect(m.source.value).toBe(SAMPLE_PLAN) + }) + it('repairs a stale activeId instead of discarding the stored plans', () => { localStorage.setItem( LIB_KEY, @@ -70,13 +82,13 @@ describe('useMacroplan — active plan binding', () => { }) describe('useMacroplan — CRUD', () => { - it('newPlan appends a sample plan and activates it', () => { + it('newPlan appends a blank plan and activates it (the sample only seeds an empty library)', () => { const m = useMacroplan() const firstId = m.activeId.value m.newPlan() expect(m.plans.value).toHaveLength(2) expect(m.activeId.value).not.toBe(firstId) - expect(m.source.value).toBe(SAMPLE_PLAN) + expect(m.source.value).toBe('') // blank page, not a copy of the sample }) it('deletePlan removes the active plan and re-points to the preceding one', () => { diff --git a/src/composables/useMacroplan.ts b/src/composables/useMacroplan.ts index f0e5027..55a79c9 100644 --- a/src/composables/useMacroplan.ts +++ b/src/composables/useMacroplan.ts @@ -1,4 +1,5 @@ import { ref, shallowRef, computed, watch } from 'vue' +import * as v from 'valibot' import { parseMacroplan } from '../model/parse' import { buildPlan } from '../model/plan' import type { Plan } from '../model/types' @@ -7,17 +8,19 @@ import { SAMPLE_PLAN } from '../data/sample' const STORAGE_KEY = 'macroplan:library' const LEGACY_KEY = 'macroplan:source' -export interface StoredPlan { - id: string - name: string - source: string -} +const StoredPlanSchema = v.object({ + id: v.string(), + name: v.string(), + source: v.string(), +}) +export type StoredPlan = v.InferOutput -interface Library { - version: 1 - activeId: string - plans: StoredPlan[] -} +const LibrarySchema = v.object({ + version: v.literal(1), + activeId: v.string(), + plans: v.array(StoredPlanSchema), +}) +type Library = v.InferOutput /** The source's title if it fully parses, else null (so the cached name only * ever updates on a valid parse). */ @@ -50,8 +53,9 @@ function loadLibrary(): Library { const raw = localStorage.getItem(STORAGE_KEY) if (raw) { try { - const lib = JSON.parse(raw) as Library - if (lib && Array.isArray(lib.plans) && lib.plans.length > 0) { + const parsed = v.safeParse(LibrarySchema, JSON.parse(raw)) + if (parsed.success && parsed.output.plans.length > 0) { + const lib = parsed.output if (!lib.plans.some((p) => p.id === lib.activeId)) lib.activeId = lib.plans[0].id result = { version: 1, activeId: lib.activeId, plans: lib.plans } } @@ -137,7 +141,11 @@ export function useMacroplan() { if (lib.value.plans.some((p) => p.id === id)) switchTo(id) }, newPlan: () => { - const p = newStoredPlan(SAMPLE_PLAN) + // A new plan starts blank. The bundled sample is reserved for a genuinely + // empty library — first run (loadLibrary) or deleting the last plan + // (deletePlan) — and the ≥1-plan invariant keeps the library non-empty + // here, so "+ New" never re-clones the sample over an existing library. + const p = newStoredPlan('') lib.value.plans.push(p) switchTo(p.id) }, diff --git a/src/model/parse.ts b/src/model/parse.ts index dfb8bcc..1d564dd 100644 --- a/src/model/parse.ts +++ b/src/model/parse.ts @@ -1,6 +1,7 @@ +import * as v from 'valibot' import { parse as parseToml } from 'smol-toml' import { toYmd } from './week' -import type { RawPlan, RawFeature, RawMilestone, StatusLevel } from './types' +import type { RawPlan, StatusLevel } from './types' /** Thrown for any malformed source — message is safe to show the author. */ export class PlanParseError extends Error { @@ -10,7 +11,36 @@ export class PlanParseError extends Error { } } -const STATUSES: StatusLevel[] = ['green', 'orange', 'red'] +const STATUSES = ['green', 'orange', 'red'] as const satisfies readonly StatusLevel[] + +// ── Field schemas ────────────────────────────────────────────────────────── +// A TOML date (smol-toml returns a Date subclass) or a yyyy-mm-dd string, +// normalized to yyyy-mm-dd via `toYmd`. +const Ymd = v.pipe( + v.union([v.date(), v.string()], 'must be a date (e.g. 2026-06-01)'), + v.transform((value: string | Date) => toYmd(value)), +) + +const Status = v.picklist(STATUSES, `must be one of ${STATUSES.join(', ')}`) + +const Name = v.pipe(v.string('is required'), v.nonEmpty('is required')) + +const FeatureSchema = v.object({ + name: Name, + start: Ymd, + original: Ymd, + reestimates: v.optional(v.array(Ymd, 'must be a list of dates'), []), + delivered: v.optional(Ymd), + learning: v.optional(v.string()), + status: v.optional(Status), + note: v.optional(v.string()), +}) + +const MilestoneSchema = v.object({ + name: Name, + week: Ymd, + requires: v.optional(v.array(v.string('must be a feature name'), 'must be a list of feature names'), []), +}) /** Parse + validate a Macroplan TOML source into the raw model. */ export function parseMacroplan(source: string): RawPlan { @@ -21,67 +51,62 @@ export function parseMacroplan(source: string): RawPlan { throw new PlanParseError(e instanceof Error ? e.message : String(e)) } - const features = asBlocks(data.feature, 'feature').map(parseFeature) - const milestones = asBlocks(data.milestone, 'milestone').map(parseMilestone) - return { title: data.title != null ? String(data.title) : 'Untitled Macroplan', - start: data.start != null ? toYmdOr('plan', 'start', data.start) : undefined, - end: data.end != null ? toYmdOr('plan', 'end', data.end) : undefined, - features, - milestones, + start: data.start != null ? check(Ymd, data.start, 'plan', 'start') : undefined, + end: data.end != null ? check(Ymd, data.end, 'plan', 'end') : undefined, + features: asBlocks(data.feature, 'feature').map((f, i) => + check(FeatureSchema, f, blockWhere('feature', f, i)), + ), + milestones: asBlocks(data.milestone, 'milestone').map((m, i) => + check(MilestoneSchema, m, blockWhere('milestone', m, i)), + ), } } -function asBlocks(value: unknown, key: string): Record[] { +function asBlocks(value: unknown, key: string): unknown[] { if (value == null) return [] if (!Array.isArray(value)) { throw new PlanParseError(`\`${key}\` must be written as [[${key}]] blocks`) } - return value as Record[] + return value } -function parseFeature(f: Record, i: number): RawFeature { - const where = f.name ? `feature "${String(f.name)}"` : `feature #${i + 1}` - if (!f.name) throw new PlanParseError(`${where}: missing \`name\``) - if (f.start == null) throw new PlanParseError(`${where}: missing \`start\` date`) - if (f.original == null) throw new PlanParseError(`${where}: missing \`original\` estimate date`) - if (f.status != null && !STATUSES.includes(f.status as StatusLevel)) { - throw new PlanParseError(`${where}: \`status\` must be one of ${STATUSES.join(', ')}`) - } - if (f.reestimates != null && !Array.isArray(f.reestimates)) { - throw new PlanParseError(`${where}: \`reestimates\` must be a list of dates`) - } - return { - name: String(f.name), - start: toYmdOr(where, 'start', f.start), - original: toYmdOr(where, 'original', f.original), - reestimates: ((f.reestimates as unknown[]) ?? []).map((d) => toYmdOr(where, 'reestimates', d)), - delivered: f.delivered != null ? toYmdOr(where, 'delivered', f.delivered) : undefined, - learning: f.learning != null ? String(f.learning) : undefined, - status: f.status as StatusLevel | undefined, - note: f.note != null ? String(f.note) : undefined, - } +/** "feature \"Payments\"" when the block carries a name, else "feature #2". */ +function blockWhere(kind: string, block: unknown, i: number): string { + const name = + block != null && typeof block === 'object' && 'name' in block + ? (block as { name: unknown }).name + : undefined + return name != null && name !== '' ? `${kind} "${String(name)}"` : `${kind} #${i + 1}` } -function parseMilestone(m: Record, i: number): RawMilestone { - const where = m.name ? `milestone "${String(m.name)}"` : `milestone #${i + 1}` - if (!m.name) throw new PlanParseError(`${where}: missing \`name\``) - if (m.week == null) throw new PlanParseError(`${where}: missing \`week\` date`) - if (m.requires != null && !Array.isArray(m.requires)) { - throw new PlanParseError(`${where}: \`requires\` must be a list of feature names`) - } - return { - name: String(m.name), - week: toYmdOr(where, 'week', m.week), - requires: ((m.requires as unknown[]) ?? []).map(String), +/** Validate `value` against `schema`, raising a contextual PlanParseError. */ +function check( + schema: S, + value: unknown, + where: string, + field?: string, +): v.InferOutput { + const result = v.safeParse(schema, value) + if (!result.success) { + throw new PlanParseError(`${where}: ${friendly(result.issues[0], field)}`) } + return result.output } -function toYmdOr(where: string, field: string, value: unknown): string { - try { - return toYmd(value) - } catch { - throw new PlanParseError(`${where}: \`${field}\` must be a date (e.g. 2026-06-01)`) - } +type Issue = { + readonly message: string + readonly received: string + readonly path?: ReadonlyArray<{ readonly key: unknown }> +} + +/** Render an issue against the offending field, e.g. "`start` must be a date" + * — or "missing `original`" when the key is absent (valibot reports its own + * "Invalid key" wording for that, which isn't fit to show the author). */ +function friendly(issue: Issue, fallbackField?: string): string { + const key = issue.path?.[0]?.key + const field = typeof key === 'string' ? key : fallbackField + if (issue.received === 'undefined') return field ? `missing \`${field}\`` : 'missing value' + return field ? `\`${field}\` ${issue.message}` : issue.message }