feat: add valibot
This commit is contained in:
@@ -15,7 +15,7 @@ This adds the library: keep several named Macroplans, switch between them, creat
|
|||||||
**In:**
|
**In:**
|
||||||
- Multiple named Macroplans persisted in localStorage.
|
- Multiple named Macroplans persisted in localStorage.
|
||||||
- A header dropdown to switch the active plan.
|
- 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).
|
- Delete the active plan (with a confirm).
|
||||||
- Download the active plan's source as `<title-slug>.toml`.
|
- Download the active plan's source as `<title-slug>.toml`.
|
||||||
- Migration from the legacy single-key store.
|
- Migration from the legacy single-key store.
|
||||||
@@ -83,11 +83,11 @@ Exposes:
|
|||||||
| `error` | `ComputedRef<string \| null>` | Active plan's current parse error. |
|
| `error` | `ComputedRef<string \| null>` | Active plan's current parse error. |
|
||||||
| `plans` | `ComputedRef<{ id, name }[]>` | For the switcher. |
|
| `plans` | `ComputedRef<{ id, name }[]>` | For the switcher. |
|
||||||
| `activeId` | `Ref<string>` | Current selection. |
|
| `activeId` | `Ref<string>` | 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). |
|
| `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. |
|
| `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
|
### `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)
|
└─► parse ─► plan/error ─► refresh active plan's cached name (on valid title)
|
||||||
|
|
||||||
switch ─► selectPlan(id) ─► activeId changes ─► source rebinds, lastGood reset ─► re-parse ─► render
|
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
|
delete ─► confirm ─► deletePlan(activeId) ─► re-point active to preceding plan (or re-seed if last) ─► render
|
||||||
toml ─► downloadSource(source, sourceFilename(title)) ─► browser download
|
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.
|
- No storage → library seeds one sample plan, active.
|
||||||
- Legacy `macroplan:source` present → migrates to a one-plan library (name = parsed title), legacy key removed.
|
- Legacy `macroplan:source` present → migrates to a one-plan library (name = parsed title), legacy key removed.
|
||||||
- Corrupt `macroplan:library` → seeds fresh sample (no throw).
|
- 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(active)` removes it and re-points `activeId` to a remaining plan.
|
||||||
- `deletePlan` of the **last** plan re-seeds a fresh sample (invariant holds).
|
- `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.
|
- 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.
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"html-to-image": "^1.11.13",
|
"html-to-image": "^1.11.13",
|
||||||
"shiki": "^4.2.0",
|
"shiki": "^4.2.0",
|
||||||
"smol-toml": "^1.6.1",
|
"smol-toml": "^1.6.1",
|
||||||
|
"valibot": "^1.4.1",
|
||||||
"vue": "^3.5.34"
|
"vue": "^3.5.34"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
15
pnpm-lock.yaml
generated
15
pnpm-lock.yaml
generated
@@ -20,6 +20,9 @@ importers:
|
|||||||
smol-toml:
|
smol-toml:
|
||||||
specifier: ^1.6.1
|
specifier: ^1.6.1
|
||||||
version: 1.6.1
|
version: 1.6.1
|
||||||
|
valibot:
|
||||||
|
specifier: ^1.4.1
|
||||||
|
version: 1.4.1(typescript@6.0.3)
|
||||||
vue:
|
vue:
|
||||||
specifier: ^3.5.34
|
specifier: ^3.5.34
|
||||||
version: 3.5.38(typescript@6.0.3)
|
version: 3.5.38(typescript@6.0.3)
|
||||||
@@ -960,6 +963,14 @@ packages:
|
|||||||
unist-util-visit@5.1.0:
|
unist-util-visit@5.1.0:
|
||||||
resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==}
|
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:
|
vfile-message@4.0.3:
|
||||||
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
|
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
|
||||||
|
|
||||||
@@ -1966,6 +1977,10 @@ snapshots:
|
|||||||
unist-util-is: 6.0.1
|
unist-util-is: 6.0.1
|
||||||
unist-util-visit-parents: 6.0.2
|
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:
|
vfile-message@4.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/unist': 3.0.3
|
'@types/unist': 3.0.3
|
||||||
|
|||||||
@@ -34,6 +34,18 @@ describe('useMacroplan — load & migration', () => {
|
|||||||
expect(m.source.value).toBe(SAMPLE_PLAN)
|
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', () => {
|
it('repairs a stale activeId instead of discarding the stored plans', () => {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
LIB_KEY,
|
LIB_KEY,
|
||||||
@@ -70,13 +82,13 @@ describe('useMacroplan — active plan binding', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('useMacroplan — CRUD', () => {
|
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 m = useMacroplan()
|
||||||
const firstId = m.activeId.value
|
const firstId = m.activeId.value
|
||||||
m.newPlan()
|
m.newPlan()
|
||||||
expect(m.plans.value).toHaveLength(2)
|
expect(m.plans.value).toHaveLength(2)
|
||||||
expect(m.activeId.value).not.toBe(firstId)
|
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', () => {
|
it('deletePlan removes the active plan and re-points to the preceding one', () => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ref, shallowRef, computed, watch } from 'vue'
|
import { ref, shallowRef, computed, watch } from 'vue'
|
||||||
|
import * as v from 'valibot'
|
||||||
import { parseMacroplan } from '../model/parse'
|
import { parseMacroplan } from '../model/parse'
|
||||||
import { buildPlan } from '../model/plan'
|
import { buildPlan } from '../model/plan'
|
||||||
import type { Plan } from '../model/types'
|
import type { Plan } from '../model/types'
|
||||||
@@ -7,17 +8,19 @@ import { SAMPLE_PLAN } from '../data/sample'
|
|||||||
const STORAGE_KEY = 'macroplan:library'
|
const STORAGE_KEY = 'macroplan:library'
|
||||||
const LEGACY_KEY = 'macroplan:source'
|
const LEGACY_KEY = 'macroplan:source'
|
||||||
|
|
||||||
export interface StoredPlan {
|
const StoredPlanSchema = v.object({
|
||||||
id: string
|
id: v.string(),
|
||||||
name: string
|
name: v.string(),
|
||||||
source: string
|
source: v.string(),
|
||||||
}
|
})
|
||||||
|
export type StoredPlan = v.InferOutput<typeof StoredPlanSchema>
|
||||||
|
|
||||||
interface Library {
|
const LibrarySchema = v.object({
|
||||||
version: 1
|
version: v.literal(1),
|
||||||
activeId: string
|
activeId: v.string(),
|
||||||
plans: StoredPlan[]
|
plans: v.array(StoredPlanSchema),
|
||||||
}
|
})
|
||||||
|
type Library = v.InferOutput<typeof LibrarySchema>
|
||||||
|
|
||||||
/** The source's title if it fully parses, else null (so the cached name only
|
/** The source's title if it fully parses, else null (so the cached name only
|
||||||
* ever updates on a valid parse). */
|
* ever updates on a valid parse). */
|
||||||
@@ -50,8 +53,9 @@ function loadLibrary(): Library {
|
|||||||
const raw = localStorage.getItem(STORAGE_KEY)
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
if (raw) {
|
if (raw) {
|
||||||
try {
|
try {
|
||||||
const lib = JSON.parse(raw) as Library
|
const parsed = v.safeParse(LibrarySchema, JSON.parse(raw))
|
||||||
if (lib && Array.isArray(lib.plans) && lib.plans.length > 0) {
|
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
|
if (!lib.plans.some((p) => p.id === lib.activeId)) lib.activeId = lib.plans[0].id
|
||||||
result = { version: 1, activeId: lib.activeId, plans: lib.plans }
|
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)
|
if (lib.value.plans.some((p) => p.id === id)) switchTo(id)
|
||||||
},
|
},
|
||||||
newPlan: () => {
|
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)
|
lib.value.plans.push(p)
|
||||||
switchTo(p.id)
|
switchTo(p.id)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import * as v from 'valibot'
|
||||||
import { parse as parseToml } from 'smol-toml'
|
import { parse as parseToml } from 'smol-toml'
|
||||||
import { toYmd } from './week'
|
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. */
|
/** Thrown for any malformed source — message is safe to show the author. */
|
||||||
export class PlanParseError extends Error {
|
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. */
|
/** Parse + validate a Macroplan TOML source into the raw model. */
|
||||||
export function parseMacroplan(source: string): RawPlan {
|
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))
|
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 {
|
return {
|
||||||
title: data.title != null ? String(data.title) : 'Untitled Macroplan',
|
title: data.title != null ? String(data.title) : 'Untitled Macroplan',
|
||||||
start: data.start != null ? toYmdOr('plan', 'start', data.start) : undefined,
|
start: data.start != null ? check(Ymd, data.start, 'plan', 'start') : undefined,
|
||||||
end: data.end != null ? toYmdOr('plan', 'end', data.end) : undefined,
|
end: data.end != null ? check(Ymd, data.end, 'plan', 'end') : undefined,
|
||||||
features,
|
features: asBlocks(data.feature, 'feature').map((f, i) =>
|
||||||
milestones,
|
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<string, unknown>[] {
|
function asBlocks(value: unknown, key: string): unknown[] {
|
||||||
if (value == null) return []
|
if (value == null) return []
|
||||||
if (!Array.isArray(value)) {
|
if (!Array.isArray(value)) {
|
||||||
throw new PlanParseError(`\`${key}\` must be written as [[${key}]] blocks`)
|
throw new PlanParseError(`\`${key}\` must be written as [[${key}]] blocks`)
|
||||||
}
|
}
|
||||||
return value as Record<string, unknown>[]
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseFeature(f: Record<string, unknown>, i: number): RawFeature {
|
/** "feature \"Payments\"" when the block carries a name, else "feature #2". */
|
||||||
const where = f.name ? `feature "${String(f.name)}"` : `feature #${i + 1}`
|
function blockWhere(kind: string, block: unknown, i: number): string {
|
||||||
if (!f.name) throw new PlanParseError(`${where}: missing \`name\``)
|
const name =
|
||||||
if (f.start == null) throw new PlanParseError(`${where}: missing \`start\` date`)
|
block != null && typeof block === 'object' && 'name' in block
|
||||||
if (f.original == null) throw new PlanParseError(`${where}: missing \`original\` estimate date`)
|
? (block as { name: unknown }).name
|
||||||
if (f.status != null && !STATUSES.includes(f.status as StatusLevel)) {
|
: undefined
|
||||||
throw new PlanParseError(`${where}: \`status\` must be one of ${STATUSES.join(', ')}`)
|
return name != null && name !== '' ? `${kind} "${String(name)}"` : `${kind} #${i + 1}`
|
||||||
}
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseMilestone(m: Record<string, unknown>, i: number): RawMilestone {
|
/** Validate `value` against `schema`, raising a contextual PlanParseError. */
|
||||||
const where = m.name ? `milestone "${String(m.name)}"` : `milestone #${i + 1}`
|
function check<S extends v.GenericSchema>(
|
||||||
if (!m.name) throw new PlanParseError(`${where}: missing \`name\``)
|
schema: S,
|
||||||
if (m.week == null) throw new PlanParseError(`${where}: missing \`week\` date`)
|
value: unknown,
|
||||||
if (m.requires != null && !Array.isArray(m.requires)) {
|
where: string,
|
||||||
throw new PlanParseError(`${where}: \`requires\` must be a list of feature names`)
|
field?: string,
|
||||||
}
|
): v.InferOutput<S> {
|
||||||
return {
|
const result = v.safeParse(schema, value)
|
||||||
name: String(m.name),
|
if (!result.success) {
|
||||||
week: toYmdOr(where, 'week', m.week),
|
throw new PlanParseError(`${where}: ${friendly(result.issues[0], field)}`)
|
||||||
requires: ((m.requires as unknown[]) ?? []).map(String),
|
|
||||||
}
|
}
|
||||||
|
return result.output
|
||||||
}
|
}
|
||||||
|
|
||||||
function toYmdOr(where: string, field: string, value: unknown): string {
|
type Issue = {
|
||||||
try {
|
readonly message: string
|
||||||
return toYmd(value)
|
readonly received: string
|
||||||
} catch {
|
readonly path?: ReadonlyArray<{ readonly key: unknown }>
|
||||||
throw new PlanParseError(`${where}: \`${field}\` must be a date (e.g. 2026-06-01)`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user